This commit is contained in:
2026-01-06 19:39:09 +00:00
commit e15d88ef99
14 changed files with 4545 additions and 0 deletions

257
src/main.rs Normal file
View File

@@ -0,0 +1,257 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
routing::{get, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tracing::{debug, error, info, warn, instrument, Level};
// ── Shared state ──────────────────────────────────────────────────────────────
type Countries = Arc<Mutex<HashMap<String, String>>>;
#[derive(Clone)]
struct AppState {
countries: Countries,
api_keys: Arc<HashSet<String>>,
}
// ── Request / Response bodies ─────────────────────────────────────────────────
#[derive(Deserialize)]
struct UpdateLocation {
lat: f64,
lon: f64,
key: String,
}
#[derive(Deserialize)]
struct GetLocation {
key: String,
}
#[derive(Serialize)]
struct CountryResponse {
country: String,
}
// ── BigDataCloud response ────────────────────────────────────────────────────
#[derive(Deserialize, Debug)]
struct BDCResponse {
country: Option<String>,
#[serde(rename = "countryCode")]
country_code: Option<String>,
}
// ── Middleware: API Key authentication with structured logging ───────────────
#[instrument(skip(state, req, next))]
async fn api_key_middleware(
headers: HeaderMap,
State(state): State<AppState>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let client_ip: String = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip().to_string())
.unwrap_or_else(|| "unknown".into());
let api_key = headers
.get("X-API-Key")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match api_key {
Some(ref key) if state.api_keys.contains(key) => {
info!(
client_ip = %client_ip,
method = %req.method(),
uri = %req.uri(),
"AUTH OK"
);
Ok(next.run(req).await)
}
Some(_) => {
warn!(client_ip = %client_ip, "INVALID API KEY");
Err(StatusCode::UNAUTHORIZED)
}
None => {
warn!(
client_ip = %client_ip,
method = %req.method(),
uri = %req.uri(),
"MISSING X-API-Key header"
);
Err(StatusCode::UNAUTHORIZED)
}
}
}
// ── Reverse geocode using BigDataCloud ───────────────────────────────────────
#[instrument(skip_all, fields(lat = %lat, lon = %lon))]
async fn get_country_from_coords(lat: f64, lon: f64) -> Result<String, StatusCode> {
debug!("Querying BigDataCloud");
let url = format!(
"https://api.bigdatacloud.net/data/reverse-geocode-client?latitude={}&longitude={}&localityLanguage=en",
lat, lon
);
let client = reqwest::Client::builder()
.user_agent("DeviceLocationServer/1.0 (contact: your-email@example.com)") // ← CHANGE THIS!
.build()
.map_err(|e| {
error!("Failed to build reqwest client: {}", e);
StatusCode::BAD_GATEWAY
})?;
let response = client.get(&url).send().await;
match response {
Ok(res) if res.status().is_success() => {
debug!("BigDataCloud responded with 200 OK");
let data: BDCResponse = res.json().await.map_err(|e| {
error!("Failed to parse JSON from BigDataCloud: {}", e);
StatusCode::BAD_GATEWAY
})?;
let country = data
.country
.or_else(|| data.country_code.map(|cc| cc.to_uppercase()))
.ok_or_else(|| {
warn!("No country information in BigDataCloud response");
StatusCode::NOT_FOUND
})?;
info!(country = %country, "Resolved country from coordinates");
Ok(country)
}
Ok(res) => {
warn!("BigDataCloud returned error status: {}", res.status());
Err(StatusCode::BAD_GATEWAY)
}
Err(e) => {
error!("Network error contacting BigDataCloud: {}", e);
Err(StatusCode::BAD_GATEWAY)
}
}
}
// ── PUT /location ─────────────────────────────────────────────────────────────
#[instrument(skip(state, payload))]
async fn update_location(
State(state): State<AppState>,
Json(payload): Json<UpdateLocation>,
) -> Result<StatusCode, StatusCode> {
info!(
key = %payload.key,
lat = payload.lat,
lon = payload.lon,
"Received location update"
);
match get_country_from_coords(payload.lat, payload.lon).await {
Ok(country) => {
let mut countries = state.countries.lock().unwrap();
countries.insert(payload.key.clone(), country.clone());
info!(
key = %payload.key,
country = %country,
"Stored device country"
);
Ok(StatusCode::OK)
}
Err(status) => {
warn!("Geocoding failed with status: {}", status);
Err(status)
}
}
}
// ── GET /get/location ─────────────────────────────────────────────────────────
#[instrument(skip(state, payload))]
async fn get_location(
State(state): State<AppState>,
Json(payload): Json<GetLocation>,
) -> Result<Json<CountryResponse>, StatusCode> {
info!(key = %payload.key, "Requesting stored location");
let countries = state.countries.lock().unwrap();
if let Some(country) = countries.get(&payload.key) {
info!(key = %payload.key, country = %country, "Found stored country");
Ok(Json(CountryResponse {
country: country.clone(),
}))
} else {
warn!(key = %payload.key, "Device key not found");
Err(StatusCode::NOT_FOUND)
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
// Load .env file if present (great for local development)
dotenvy::dotenv().ok();
// Initialize structured logging
tracing_subscriber::fmt()
.with_max_level(Level::INFO) // Set to DEBUG for more details
.with_target(true)
.with_thread_names(false)
.pretty()
.init();
// Load API keys from environment variable
let api_keys_str = std::env::var("API_KEYS")
.expect("API_KEYS environment variable is required");
let valid_api_keys: HashSet<String> = if api_keys_str.trim().is_empty() {
HashSet::new()
} else {
api_keys_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
if valid_api_keys.is_empty() {
warn!("No API keys configured all requests will be rejected!");
} else {
info!("Loaded {} API key(s) from API_KEYS", valid_api_keys.len());
}
let state = AppState {
countries: Arc::new(Mutex::new(HashMap::new())),
api_keys: Arc::new(valid_api_keys),
};
let app = Router::new()
.route("/location", put(update_location))
.route("/get/location", get(get_location))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
api_key_middleware,
))
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
let addr = listener.local_addr().unwrap();
info!("Country Location Server starting");
info!("Listening on http://{}", addr);
info!("Using BigDataCloud for reverse geocoding");
warn!("Remember to update the User-Agent email in get_country_from_coords()!");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
}