Added key-based authentication
This commit is contained in:
87
src/app.rs
87
src/app.rs
@@ -22,6 +22,7 @@ pub enum Event {
|
||||
/// Carries the exe path captured before the binary was replaced.
|
||||
UpdateDone(std::path::PathBuf),
|
||||
UpdateError(String),
|
||||
AccessRequested(crate::error::Result<String>),
|
||||
}
|
||||
|
||||
/// A pending request to hand the terminal to an external interactive process.
|
||||
@@ -81,8 +82,9 @@ pub enum Popup {
|
||||
index: usize,
|
||||
return_selected: usize,
|
||||
},
|
||||
AccessDenied { endpoint_url: String },
|
||||
Downloading { progress: f32, message: String },
|
||||
Message { text: String, level: MessageLevel },
|
||||
Message { text: String, level: MessageLevel, on_dismiss: MessageAction },
|
||||
UpdateConfirm {
|
||||
info: updater::UpdateInfo,
|
||||
},
|
||||
@@ -108,6 +110,12 @@ pub enum MessageLevel {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MessageAction {
|
||||
Stay,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(config: Config, event_tx: UnboundedSender<Event>) -> Self {
|
||||
Self {
|
||||
@@ -143,9 +151,12 @@ impl App {
|
||||
let server_url = self.config.active_url().to_string();
|
||||
let timeout = self.config.timeout_sec;
|
||||
let tx = self.event_tx.clone();
|
||||
let identity_b64 = self.config.active_identity().map(|id| id.to_b64());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = network::fetch_menu(&server_url, timeout).await;
|
||||
let identity = identity_b64.as_deref()
|
||||
.and_then(|k| crate::identity::Identity::from_b64(k).ok());
|
||||
let result = network::fetch_menu(&server_url, timeout, identity.as_ref()).await;
|
||||
let _ = tx.send(Event::MenuLoaded(result));
|
||||
});
|
||||
}
|
||||
@@ -182,6 +193,12 @@ impl App {
|
||||
self.error = None;
|
||||
Ok(false)
|
||||
}
|
||||
Event::MenuLoaded(Err(crate::error::AppError::AccessDenied)) => {
|
||||
self.popup = Some(Popup::AccessDenied {
|
||||
endpoint_url: self.config.active_endpoint.clone(),
|
||||
});
|
||||
Ok(false)
|
||||
}
|
||||
Event::MenuLoaded(Err(e)) => {
|
||||
self.error = Some(format!("Ошибка загрузки меню: {}", e));
|
||||
Ok(false)
|
||||
@@ -276,6 +293,27 @@ impl App {
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Event::AccessRequested(Ok(msg)) => {
|
||||
let on_dismiss = if self.config.endpoints.len() == 1 && self.menu.is_none() {
|
||||
MessageAction::Exit
|
||||
} else {
|
||||
MessageAction::Stay
|
||||
};
|
||||
self.popup = Some(Popup::Message {
|
||||
text: msg,
|
||||
level: MessageLevel::Info,
|
||||
on_dismiss,
|
||||
});
|
||||
Ok(false)
|
||||
}
|
||||
Event::AccessRequested(Err(e)) => {
|
||||
self.popup = Some(Popup::Message {
|
||||
text: format!("Не удалось отправить запрос: {}", e),
|
||||
level: MessageLevel::Error,
|
||||
on_dismiss: MessageAction::Stay,
|
||||
});
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,7 +722,7 @@ impl App {
|
||||
} else {
|
||||
match edit_idx {
|
||||
None => {
|
||||
self.config.endpoints.push(crate::config::Endpoint { name, url });
|
||||
self.config.endpoints.push(crate::config::Endpoint::new(name, url));
|
||||
let _ = self.config.save();
|
||||
let new_sel = self.config.endpoints.len() - 1;
|
||||
self.popup = Some(Popup::EndpointSelector {
|
||||
@@ -773,8 +811,48 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Popup::Message { .. } => {
|
||||
Popup::AccessDenied { endpoint_url } => {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
let url = endpoint_url.clone();
|
||||
let timeout = self.config.timeout_sec;
|
||||
// Generate a key for this endpoint if it doesn't have one yet
|
||||
// (happens when an endpoint was added before auth was implemented).
|
||||
if let Some(ep) = self.config.endpoints.iter_mut().find(|ep| ep.url == url) {
|
||||
if ep.private_key.is_none() {
|
||||
ep.private_key = Some(crate::identity::Identity::generate().to_b64());
|
||||
let _ = self.config.save();
|
||||
}
|
||||
}
|
||||
let identity_b64 = self.config.endpoints.iter()
|
||||
.find(|ep| ep.url == url)
|
||||
.and_then(|ep| ep.private_key.clone());
|
||||
let tx = self.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = match identity_b64
|
||||
.as_deref()
|
||||
.and_then(|k| crate::identity::Identity::from_b64(k).ok())
|
||||
{
|
||||
Some(id) => network::request_access(&url, timeout, &id).await,
|
||||
None => Err(crate::error::AppError::Config(
|
||||
"Ключ для эндпоинта не найден".to_string(),
|
||||
)),
|
||||
};
|
||||
let _ = tx.send(Event::AccessRequested(result));
|
||||
});
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||
self.popup = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Popup::Message { on_dismiss, .. } => {
|
||||
let exit = matches!(on_dismiss, MessageAction::Exit);
|
||||
self.popup = None;
|
||||
if exit { return Ok(true); }
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -950,6 +1028,7 @@ impl App {
|
||||
self.popup = Some(Popup::Message {
|
||||
text: format!("Скачивание {} пока не реализовано", url),
|
||||
level: MessageLevel::Info,
|
||||
on_dismiss: MessageAction::Stay,
|
||||
});
|
||||
}
|
||||
Action::DownloadAndRun { .. } => {}
|
||||
|
||||
@@ -7,6 +7,24 @@ use std::path::PathBuf;
|
||||
pub struct Endpoint {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub private_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
/// Creates a new endpoint with a freshly generated Ed25519 key pair.
|
||||
pub fn new(name: String, url: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
url,
|
||||
private_key: Some(crate::identity::Identity::generate().to_b64()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> Option<crate::identity::Identity> {
|
||||
self.private_key.as_deref()
|
||||
.and_then(|k| crate::identity::Identity::from_b64(k).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
@@ -36,9 +54,7 @@ fn default_selected_bg() -> String {
|
||||
}
|
||||
|
||||
fn default_theme() -> Theme {
|
||||
Theme {
|
||||
selected_bg: default_selected_bg(),
|
||||
}
|
||||
Theme { selected_bg: default_selected_bg() }
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
@@ -61,6 +77,13 @@ impl Config {
|
||||
&self.active_endpoint
|
||||
}
|
||||
|
||||
/// Returns the identity for the currently active endpoint, if any.
|
||||
pub fn active_identity(&self) -> Option<crate::identity::Identity> {
|
||||
self.endpoints.iter()
|
||||
.find(|ep| ep.url == self.active_endpoint)
|
||||
.and_then(|ep| ep.identity())
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
let content = fs::read_to_string(&path)?;
|
||||
@@ -69,10 +92,7 @@ impl Config {
|
||||
if cfg.endpoints.is_empty() {
|
||||
if let Some(url) = cfg.server_url.take() {
|
||||
cfg.active_endpoint = url.clone();
|
||||
cfg.endpoints.push(Endpoint {
|
||||
name: "Default".to_string(),
|
||||
url,
|
||||
});
|
||||
cfg.endpoints.push(Endpoint { name: "Default".to_string(), url, private_key: None });
|
||||
}
|
||||
}
|
||||
if cfg.active_endpoint.is_empty() {
|
||||
@@ -87,10 +107,7 @@ impl Config {
|
||||
let path = config_path();
|
||||
let config = Config {
|
||||
server_url: None,
|
||||
endpoints: vec![Endpoint {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
}],
|
||||
endpoints: vec![Endpoint::new(name.to_string(), url.to_string())],
|
||||
active_endpoint: url.to_string(),
|
||||
timeout_sec: 10,
|
||||
theme: default_theme(),
|
||||
@@ -127,6 +144,9 @@ impl Config {
|
||||
escape_toml(&ep.name),
|
||||
escape_toml(&ep.url),
|
||||
));
|
||||
if let Some(key) = &ep.private_key {
|
||||
s.push_str(&format!("private_key = \"{}\"\n", escape_toml(key)));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum AppError {
|
||||
Child(String),
|
||||
#[error("Protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("Access denied by server")]
|
||||
AccessDenied,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
46
src/identity.rs
Normal file
46
src/identity.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
pub struct Identity {
|
||||
key: SigningKey,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn generate() -> Self {
|
||||
Self { key: SigningKey::generate(&mut OsRng) }
|
||||
}
|
||||
|
||||
pub fn from_b64(s: &str) -> Result<Self> {
|
||||
let bytes = B64.decode(s)?;
|
||||
let arr: [u8; 32] = bytes.try_into().map_err(|_| anyhow!("invalid key length"))?;
|
||||
Ok(Self { key: SigningKey::from_bytes(&arr) })
|
||||
}
|
||||
|
||||
pub fn to_b64(&self) -> String {
|
||||
B64.encode(self.key.to_bytes())
|
||||
}
|
||||
|
||||
pub fn public_key_b64(&self) -> String {
|
||||
B64.encode(self.key.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Signs `"METHOD\nPATH\nTIMESTAMP"` and returns base64url signature.
|
||||
pub fn sign(&self, method: &str, path: &str, timestamp: u64) -> String {
|
||||
let msg = format!("{}\n{}\n{}", method, path, timestamp);
|
||||
B64.encode(self.key.sign(msg.as_bytes()).to_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_hostname() -> String {
|
||||
let mut buf = vec![0u8; 256];
|
||||
unsafe {
|
||||
if libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) == 0 {
|
||||
let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..len]).into_owned()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod app;
|
||||
mod config;
|
||||
mod error;
|
||||
mod executor;
|
||||
mod identity;
|
||||
mod menu;
|
||||
mod network;
|
||||
mod text_input;
|
||||
|
||||
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())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
32
src/ui.rs
32
src/ui.rs
@@ -321,18 +321,19 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) {
|
||||
}
|
||||
}
|
||||
|
||||
Popup::Message { text, level } => {
|
||||
Popup::Message { text, level, .. } => {
|
||||
let color = match level {
|
||||
MessageLevel::Info => Color::Green,
|
||||
MessageLevel::Warn => Color::Yellow,
|
||||
MessageLevel::Error => Color::Red,
|
||||
};
|
||||
let content = format!("{}\n\n[любая клавиша] Закрыть", text);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Сообщение")
|
||||
.border_style(Style::default().fg(color));
|
||||
f.render_widget(
|
||||
Paragraph::new(text.as_str()).block(block).alignment(Alignment::Center).wrap(Wrap { trim: true }),
|
||||
Paragraph::new(content.as_str()).block(block).alignment(Alignment::Center).wrap(Wrap { trim: true }),
|
||||
popup_area,
|
||||
);
|
||||
}
|
||||
@@ -568,6 +569,33 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) {
|
||||
);
|
||||
}
|
||||
|
||||
Popup::AccessDenied { endpoint_url } => {
|
||||
let popup_area = centered_rect(55, 30, area);
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let name = config.endpoints.iter()
|
||||
.find(|ep| &ep.url == endpoint_url)
|
||||
.map(|ep| ep.name.as_str())
|
||||
.unwrap_or(endpoint_url.as_str());
|
||||
|
||||
let text = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::raw(format!(" Сервер «{}» запретил доступ.", name))),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" [Y] Запросить доступ [N / Esc] Отмена",
|
||||
Style::default().fg(Color::Yellow),
|
||||
)),
|
||||
];
|
||||
f.render_widget(
|
||||
Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Доступ запрещён ")
|
||||
.border_style(Style::default().fg(Color::Red)))
|
||||
.wrap(Wrap { trim: true }),
|
||||
popup_area,
|
||||
);
|
||||
}
|
||||
|
||||
Popup::ConfirmDeleteEndpoint { index, .. } => {
|
||||
let popup_area = centered_rect(50, 30, area);
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
Reference in New Issue
Block a user