From 17bf4d546af19ddf9550bb1690b1acc7a1c7fd7d Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Fri, 29 May 2026 05:42:38 +0700 Subject: [PATCH] Added key-based authentication --- Cargo.lock | 131 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 5 +- src/app.rs | 87 +++++++++++++++++++++++++++-- src/config.rs | 42 ++++++++++---- src/error.rs | 2 + src/identity.rs | 46 ++++++++++++++++ src/main.rs | 1 + src/network.rs | 143 ++++++++++++++++++++++++++++++++++++++++++------ src/ui.rs | 32 ++++++++++- 9 files changed, 452 insertions(+), 37 deletions(-) create mode 100644 src/identity.rs diff --git a/Cargo.lock b/Cargo.lock index b103fab..ea07819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -149,6 +155,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -214,6 +226,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -254,6 +293,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -336,6 +385,31 @@ dependencies = [ "litrs", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -377,6 +451,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1123,13 +1203,16 @@ dependencies = [ [[package]] name = "ostiary" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", + "base64", "crossterm", "dirs", + "ed25519-dalek", "futures", "libc", + "rand 0.8.5", "ratatui", "reqwest", "serde", @@ -1277,6 +1360,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1408,6 +1501,8 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -1417,10 +1512,20 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1436,6 +1541,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1845,6 +1953,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -1873,6 +1990,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index d1f6a1a..748f607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.1.0" +version = "1.1.1" edition = "2024" [dependencies] @@ -18,3 +18,6 @@ tokio-util = "0.7" toml = "0.8" serde_path_to_error = "0.1" libc = "0.2" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +base64 = "0.22" diff --git a/src/app.rs b/src/app.rs index e47f9f7..7096816 100644 --- a/src/app.rs +++ b/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), } /// 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) -> 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 { .. } => {} diff --git a/src/config.rs b/src/config.rs index 601c359..4a63d15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, +} + +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 { + 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 { + self.endpoints.iter() + .find(|ep| ep.url == self.active_endpoint) + .and_then(|ep| ep.identity()) + } + pub fn load() -> Result { 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 } diff --git a/src/error.rs b/src/error.rs index 6c6301b..420165a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,8 @@ pub enum AppError { Child(String), #[error("Protocol error: {0}")] Protocol(String), + #[error("Access denied by server")] + AccessDenied, } pub type Result = std::result::Result; diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..27da9ff --- /dev/null +++ b/src/identity.rs @@ -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 { + 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() + } + } +} diff --git a/src/main.rs b/src/main.rs index dfd09f3..7e8bc4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod app; mod config; mod error; mod executor; +mod identity; mod menu; mod network; mod text_input; diff --git a/src/network.rs b/src/network.rs index 3e1a2aa..7fe4f33 100644 --- a/src/network.rs +++ b/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 { +pub async fn fetch_menu(server_url: &str, timeout_sec: u64, identity: Option<&Identity>) -> Result { 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::>(&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 { + 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::(&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::().await { + if body.error == "access_denied" { + return AppError::AccessDenied; } } + AppError::Config("Сервер вернул 403 Forbidden".to_string()) +} + +fn parse_menu_body(text: &str) -> Result { + 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::>(text) { + Ok(items) => Ok(MenuRoot { version: "1.0".to_string(), menu: items }), + Err(_) => Err(AppError::Json(e.into_inner())), + }, + } } diff --git a/src/ui.rs b/src/ui.rs index 73a4539..20e989c 100644 --- a/src/ui.rs +++ b/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);