Added key-based authentication
This commit is contained in:
143
src/network.rs
143
src/network.rs
@@ -1,29 +1,138 @@
|
||||
use crate::error::Result;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::identity::Identity;
|
||||
use crate::menu::{MenuItem, MenuRoot};
|
||||
use reqwest::Client;
|
||||
use serde_path_to_error as path_to_error;
|
||||
|
||||
pub async fn fetch_menu(server_url: &str, timeout_sec: u64) -> Result<MenuRoot> {
|
||||
pub async fn fetch_menu(server_url: &str, timeout_sec: u64, identity: Option<&Identity>) -> Result<MenuRoot> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_sec))
|
||||
.build()?;
|
||||
|
||||
let resp = client.get(server_url).send().await?;
|
||||
let text = resp.text().await?;
|
||||
let mut req = client.get(server_url);
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(&text);
|
||||
match path_to_error::deserialize(&mut deserializer) {
|
||||
Ok(root) => Ok(root),
|
||||
Err(e) => {
|
||||
// Если не удалось распарсить как объект с полем "menu",
|
||||
// пробуем интерпретировать ответ как прямой массив пунктов меню.
|
||||
match serde_json::from_str::<Vec<MenuItem>>(&text) {
|
||||
Ok(items) => Ok(MenuRoot {
|
||||
version: "1.0".to_string(),
|
||||
menu: items,
|
||||
}),
|
||||
Err(_) => Err(crate::error::AppError::Json(e.into_inner())),
|
||||
}
|
||||
if let Some(id) = identity {
|
||||
let timestamp = unix_now();
|
||||
let path = url_path(server_url);
|
||||
req = req
|
||||
.header("X-Public-Key", id.public_key_b64())
|
||||
.header("X-Timestamp", timestamp.to_string())
|
||||
.header("X-Signature", id.sign("GET", &path, timestamp));
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(parse_access_denied(resp).await);
|
||||
}
|
||||
|
||||
let text = resp.text().await?;
|
||||
parse_menu_body(&text)
|
||||
}
|
||||
|
||||
/// Sends an enrollment request to `{server_url}/access-request`.
|
||||
/// The server stores the public key + source IP for admin approval.
|
||||
/// Returns a human-readable status string on success.
|
||||
pub async fn request_access(server_url: &str, timeout_sec: u64, identity: &Identity) -> Result<String> {
|
||||
let access_url = enrollment_url(server_url);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_sec))
|
||||
.build()?;
|
||||
|
||||
let resp = client
|
||||
.post(&access_url)
|
||||
.json(&serde_json::json!({
|
||||
"public_key": identity.public_key_b64(),
|
||||
"hostname": crate::identity::current_hostname(),
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
let json_status = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v["status"].as_str().map(|s| s.to_string()));
|
||||
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
let msg = match json_status.as_deref() {
|
||||
Some("banned") => "Ваш IP заблокирован администратором.".to_string(),
|
||||
_ => format!("Сервер вернул {}: {}", status, body),
|
||||
};
|
||||
return Ok(msg);
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(AppError::Config(format!("Сервер вернул {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let msg = match json_status.as_deref() {
|
||||
Some("pending") => "Запрос отправлен. Ожидайте одобрения администратора.".to_string(),
|
||||
Some("already_pending") => "Запрос уже был отправлен ранее. Ожидайте одобрения.".to_string(),
|
||||
Some("already_authorized") => "Ключ уже авторизован. Нажмите R для обновления меню.".to_string(),
|
||||
_ => format!("Ответ сервера: {}", body),
|
||||
};
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Builds the enrollment URL: `access-request.php` in the same directory as the menu URL.
|
||||
/// `https://host/api/menu/index.php` → `https://host/api/menu/access-request.php`
|
||||
/// `https://host/api/menu/` → `https://host/api/menu/access-request.php`
|
||||
fn enrollment_url(server_url: &str) -> String {
|
||||
reqwest::Url::parse(server_url)
|
||||
.map(|mut u| {
|
||||
let path = u.path().to_string();
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
// If the last path segment looks like a file (contains '.'), use its parent dir.
|
||||
let dir = match trimmed.rfind('/') {
|
||||
Some(pos) if trimmed[pos + 1..].contains('.') => &trimmed[..pos],
|
||||
_ => trimmed,
|
||||
};
|
||||
u.set_path(&format!("{}/access-request.php", dir));
|
||||
u.set_query(None);
|
||||
u.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| format!("{}/access-request.php", server_url.trim_end_matches('/')))
|
||||
}
|
||||
|
||||
/// Extracts just the path component from a URL string.
|
||||
fn url_path(url: &str) -> String {
|
||||
reqwest::Url::parse(url)
|
||||
.map(|u| u.path().to_string())
|
||||
.unwrap_or_else(|_| "/".to_string())
|
||||
}
|
||||
|
||||
/// Returns `AppError::AccessDenied` when the 403 body contains `{"error":"access_denied"}`,
|
||||
/// otherwise a generic config error.
|
||||
async fn parse_access_denied(resp: reqwest::Response) -> AppError {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrBody { error: String }
|
||||
if let Ok(body) = resp.json::<ErrBody>().await {
|
||||
if body.error == "access_denied" {
|
||||
return AppError::AccessDenied;
|
||||
}
|
||||
}
|
||||
AppError::Config("Сервер вернул 403 Forbidden".to_string())
|
||||
}
|
||||
|
||||
fn parse_menu_body(text: &str) -> Result<MenuRoot> {
|
||||
let mut de = serde_json::Deserializer::from_str(text);
|
||||
match path_to_error::deserialize(&mut de) {
|
||||
Ok(root) => Ok(root),
|
||||
Err(e) => match serde_json::from_str::<Vec<MenuItem>>(text) {
|
||||
Ok(items) => Ok(MenuRoot { version: "1.0".to_string(), menu: items }),
|
||||
Err(_) => Err(AppError::Json(e.into_inner())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user