2026-05-20 01:02:01 +08:00
|
|
|
//! Interactive TUI for configuring aether-tunnel.
|
2026-02-07 19:20:09 +08:00
|
|
|
//!
|
2026-05-20 01:02:01 +08:00
|
|
|
//! Launched via `aether-tunnel setup [path]`. Presents a full-screen form
|
2026-02-07 19:20:09 +08:00
|
|
|
//! backed by ratatui where the user can navigate fields, edit values, and
|
2026-02-25 21:59:29 +08:00
|
|
|
//! save to a TOML config file. Supports multi-server configuration via
|
|
|
|
|
//! a tabbed interface.
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
use std::io;
|
2026-04-12 16:02:38 +08:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-02-07 19:20:09 +08:00
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
|
|
|
use crossterm::execute;
|
|
|
|
|
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
|
|
|
|
use ratatui::backend::CrosstermBackend;
|
|
|
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
|
|
|
use ratatui::Frame;
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
use crate::config::{
|
2026-07-30 01:03:05 +08:00
|
|
|
ConfigFile, ServerEntry, TunnelLogDestinationArg, TunnelLogRotationArg,
|
|
|
|
|
DEFAULT_HEARTBEAT_INTERVAL_SECS, DEFAULT_LOG_MAX_FILES, DEFAULT_LOG_RETENTION_DAYS,
|
2026-04-12 16:02:38 +08:00
|
|
|
};
|
2026-05-07 15:01:18 +08:00
|
|
|
use crate::egress_proxy::UpstreamProxyConfig;
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-08 16:10:13 +08:00
|
|
|
/// Outcome of the setup wizard, returned to the caller.
|
|
|
|
|
pub enum SetupOutcome {
|
2026-04-13 17:20:22 +08:00
|
|
|
/// Config saved and the selected host service was installed.
|
2026-02-08 16:10:13 +08:00
|
|
|
ServiceInstalled,
|
2026-05-20 01:02:01 +08:00
|
|
|
/// Config saved; no service -- caller should start the tunnel directly.
|
2026-02-08 16:10:13 +08:00
|
|
|
ReadyToRun(PathBuf),
|
|
|
|
|
/// User quit without saving.
|
|
|
|
|
Cancelled,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
/// Column width reserved for the field label (chars).
|
|
|
|
|
const LABEL_WIDTH: usize = 22;
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Field types --------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
|
|
|
enum FieldKind {
|
|
|
|
|
Text,
|
|
|
|
|
Secret,
|
|
|
|
|
Bool,
|
|
|
|
|
LogLevel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Field {
|
|
|
|
|
label: &'static str,
|
|
|
|
|
key: &'static str,
|
|
|
|
|
value: String,
|
|
|
|
|
kind: FieldKind,
|
|
|
|
|
required: bool,
|
|
|
|
|
help: &'static str,
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Server tab ---------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
/// A single server tab's editable fields.
|
|
|
|
|
struct ServerTab {
|
2026-02-07 19:20:09 +08:00
|
|
|
fields: Vec<Field>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
impl ServerTab {
|
|
|
|
|
fn new() -> Self {
|
2026-02-07 19:20:09 +08:00
|
|
|
Self {
|
|
|
|
|
fields: vec![
|
|
|
|
|
Field {
|
|
|
|
|
label: "Aether URL",
|
|
|
|
|
key: "aether_url",
|
|
|
|
|
value: String::new(),
|
|
|
|
|
kind: FieldKind::Text,
|
|
|
|
|
required: true,
|
2026-02-25 21:59:29 +08:00
|
|
|
help: "Aether URL (e.g. https://aether.example.com)",
|
2026-02-07 19:20:09 +08:00
|
|
|
},
|
|
|
|
|
Field {
|
|
|
|
|
label: "Management Token",
|
|
|
|
|
key: "management_token",
|
|
|
|
|
value: String::new(),
|
|
|
|
|
kind: FieldKind::Secret,
|
|
|
|
|
required: true,
|
2026-02-25 21:59:29 +08:00
|
|
|
help: "Aether Management Token (ae_xxx)",
|
2026-02-07 19:20:09 +08:00
|
|
|
},
|
|
|
|
|
Field {
|
|
|
|
|
label: "Node Name",
|
|
|
|
|
key: "node_name",
|
2026-04-12 16:02:38 +08:00
|
|
|
value: String::new(),
|
2026-02-07 19:20:09 +08:00
|
|
|
kind: FieldKind::Text,
|
|
|
|
|
required: true,
|
2026-02-25 21:59:29 +08:00
|
|
|
help: "Node name for identification in Aether dashboard",
|
2026-02-07 19:20:09 +08:00
|
|
|
},
|
2026-05-21 16:07:56 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Tunnel Security",
|
|
|
|
|
key: "tunnel_security",
|
2026-05-22 15:20:36 +08:00
|
|
|
value: String::new(),
|
2026-05-21 16:07:56 +08:00
|
|
|
kind: FieldKind::Text,
|
|
|
|
|
required: false,
|
2026-05-22 15:20:36 +08:00
|
|
|
help: "off or non_tls_required; omit to auto-enable for http:// plus a key",
|
2026-05-21 16:07:56 +08:00
|
|
|
},
|
|
|
|
|
Field {
|
|
|
|
|
label: "Tunnel Encryption Key",
|
|
|
|
|
key: "tunnel_encryption_key",
|
|
|
|
|
value: String::new(),
|
|
|
|
|
kind: FieldKind::Secret,
|
|
|
|
|
required: false,
|
|
|
|
|
help: "Base64 32-byte PSK; required when Tunnel Security is non_tls_required",
|
|
|
|
|
},
|
2026-02-25 21:59:29 +08:00
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn from_entry(entry: &ServerEntry) -> Self {
|
|
|
|
|
let mut tab = Self::new();
|
|
|
|
|
tab.fields[0].value = entry.aether_url.clone();
|
|
|
|
|
tab.fields[1].value = entry.management_token.clone();
|
|
|
|
|
if let Some(ref name) = entry.node_name {
|
|
|
|
|
tab.fields[2].value = name.clone();
|
|
|
|
|
}
|
2026-05-21 16:07:56 +08:00
|
|
|
if let Some(security) = entry.tunnel_security {
|
|
|
|
|
tab.fields[3].value = security.to_string();
|
|
|
|
|
}
|
|
|
|
|
if let Some(ref key) = entry.tunnel_encryption_key {
|
|
|
|
|
tab.fields[4].value = key.clone();
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
tab
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- App state ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[derive(PartialEq)]
|
|
|
|
|
enum Mode {
|
|
|
|
|
Normal,
|
|
|
|
|
Editing,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct App {
|
|
|
|
|
server_tabs: Vec<ServerTab>,
|
|
|
|
|
active_tab: usize,
|
|
|
|
|
global_fields: Vec<Field>,
|
|
|
|
|
selected: usize,
|
|
|
|
|
mode: Mode,
|
|
|
|
|
edit_buffer: String,
|
|
|
|
|
edit_cursor: usize,
|
|
|
|
|
config_path: PathBuf,
|
|
|
|
|
modified: bool,
|
|
|
|
|
message: Option<(String, Instant, bool)>,
|
|
|
|
|
scroll_offset: usize,
|
|
|
|
|
saved_once: bool,
|
|
|
|
|
pending_quit: bool,
|
|
|
|
|
confirm_delete: bool,
|
|
|
|
|
}
|
|
|
|
|
impl App {
|
|
|
|
|
fn new(config_path: PathBuf) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
server_tabs: vec![ServerTab::new()],
|
|
|
|
|
active_tab: 0,
|
|
|
|
|
global_fields: vec![
|
2026-05-08 22:03:17 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Egress Proxy",
|
|
|
|
|
key: "upstream_proxy_url",
|
|
|
|
|
value: String::new(),
|
|
|
|
|
kind: FieldKind::Text,
|
|
|
|
|
required: false,
|
|
|
|
|
help:
|
|
|
|
|
"Optional egress proxy for Aether tunnel/API and provider requests, e.g. http://127.0.0.1:8080 or socks5h://127.0.0.1:1080",
|
|
|
|
|
},
|
2026-02-08 13:33:08 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Install Service",
|
|
|
|
|
key: "install_service",
|
|
|
|
|
value: if super::service::is_available() {
|
|
|
|
|
"true"
|
|
|
|
|
} else {
|
|
|
|
|
"false"
|
|
|
|
|
}
|
|
|
|
|
.into(),
|
|
|
|
|
kind: FieldKind::Bool,
|
2026-04-12 16:02:38 +08:00
|
|
|
required: false,
|
2026-04-13 17:20:22 +08:00
|
|
|
help: "Install as managed service (requires root) -- Enter to toggle",
|
2026-02-07 19:20:09 +08:00
|
|
|
},
|
2026-04-12 16:02:38 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Log Level",
|
|
|
|
|
key: "log_level",
|
|
|
|
|
value: "info".into(),
|
|
|
|
|
kind: FieldKind::LogLevel,
|
|
|
|
|
required: true,
|
|
|
|
|
help: "Log level -- Enter to cycle: trace / debug / info / warn / error",
|
|
|
|
|
},
|
|
|
|
|
Field {
|
|
|
|
|
label: "Save Logs to File",
|
|
|
|
|
key: "save_logs_to_file",
|
2026-05-15 19:43:59 +08:00
|
|
|
value: "true".into(),
|
2026-04-12 16:02:38 +08:00
|
|
|
kind: FieldKind::Bool,
|
|
|
|
|
required: false,
|
|
|
|
|
help: "Write pretty .log files with daily rotation and 7-day retention",
|
|
|
|
|
},
|
2026-04-15 00:31:36 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Allow Private Targets",
|
|
|
|
|
key: "allow_private_targets",
|
|
|
|
|
value: "true".into(),
|
|
|
|
|
kind: FieldKind::Bool,
|
|
|
|
|
required: false,
|
|
|
|
|
help:
|
|
|
|
|
"Allow proxying private/reserved upstream IPs by default; takes effect after restart",
|
|
|
|
|
},
|
2026-04-12 16:02:38 +08:00
|
|
|
Field {
|
|
|
|
|
label: "Heartbeat Interval",
|
|
|
|
|
key: "heartbeat_interval",
|
|
|
|
|
value: DEFAULT_HEARTBEAT_INTERVAL_SECS.to_string(),
|
|
|
|
|
kind: FieldKind::Text,
|
|
|
|
|
required: false,
|
2026-04-14 22:51:02 +08:00
|
|
|
help: "Heartbeat interval in seconds; default is 5",
|
2026-04-12 16:02:38 +08:00
|
|
|
},
|
2026-02-07 19:20:09 +08:00
|
|
|
],
|
|
|
|
|
selected: 0,
|
|
|
|
|
mode: Mode::Normal,
|
|
|
|
|
edit_buffer: String::new(),
|
|
|
|
|
edit_cursor: 0,
|
|
|
|
|
config_path,
|
|
|
|
|
modified: false,
|
|
|
|
|
message: None,
|
|
|
|
|
scroll_offset: 0,
|
|
|
|
|
saved_once: false,
|
2026-02-08 16:10:13 +08:00
|
|
|
pending_quit: false,
|
2026-02-25 21:59:29 +08:00
|
|
|
confirm_delete: false,
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Field accessors (unified index across server + global) ---------------
|
|
|
|
|
|
|
|
|
|
fn server_field_count(&self) -> usize {
|
|
|
|
|
self.server_tabs[self.active_tab].fields.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn total_field_count(&self) -> usize {
|
|
|
|
|
self.server_field_count() + self.global_fields.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn selected_field(&self) -> &Field {
|
|
|
|
|
let sc = self.server_field_count();
|
|
|
|
|
if self.selected < sc {
|
|
|
|
|
&self.server_tabs[self.active_tab].fields[self.selected]
|
|
|
|
|
} else {
|
|
|
|
|
&self.global_fields[self.selected - sc]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn selected_field_mut(&mut self) -> &mut Field {
|
|
|
|
|
let sc = self.server_field_count();
|
|
|
|
|
if self.selected < sc {
|
|
|
|
|
&mut self.server_tabs[self.active_tab].fields[self.selected]
|
|
|
|
|
} else {
|
|
|
|
|
&mut self.global_fields[self.selected - sc]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn clamp_selection(&mut self) {
|
|
|
|
|
let max = self.total_field_count();
|
|
|
|
|
if self.selected >= max {
|
|
|
|
|
self.selected = max.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
self.scroll_offset = 0;
|
|
|
|
|
self.confirm_delete = false;
|
|
|
|
|
}
|
|
|
|
|
// -- Config <-> fields -----------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
fn load_from_file(&mut self) {
|
|
|
|
|
if let Ok(cfg) = ConfigFile::load(&self.config_path) {
|
|
|
|
|
self.apply_config(&cfg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_config(&mut self, cfg: &ConfigFile) {
|
2026-02-25 21:59:29 +08:00
|
|
|
// Global fields
|
|
|
|
|
for field in &mut self.global_fields {
|
2026-02-07 19:20:09 +08:00
|
|
|
let val: Option<String> = match field.key {
|
|
|
|
|
"log_level" => cfg.log_level.clone(),
|
2026-04-12 16:02:38 +08:00
|
|
|
"save_logs_to_file" => cfg.log_destination.map(|value| {
|
|
|
|
|
matches!(
|
|
|
|
|
value,
|
2026-05-20 01:02:01 +08:00
|
|
|
TunnelLogDestinationArg::File | TunnelLogDestinationArg::Both
|
2026-04-12 16:02:38 +08:00
|
|
|
)
|
|
|
|
|
.to_string()
|
|
|
|
|
}),
|
2026-04-15 00:31:36 +08:00
|
|
|
"allow_private_targets" => cfg.allow_private_targets.map(|v| v.to_string()),
|
2026-04-12 16:02:38 +08:00
|
|
|
"heartbeat_interval" => cfg.heartbeat_interval.map(|v| v.to_string()),
|
2026-05-08 22:03:17 +08:00
|
|
|
"upstream_proxy_url" => cfg.upstream_proxy_url.clone(),
|
2026-02-07 19:20:09 +08:00
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
if let Some(v) = val {
|
|
|
|
|
field.value = v;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
|
|
|
|
|
// Server tabs
|
2026-04-14 22:51:02 +08:00
|
|
|
let servers = cfg.servers.clone();
|
2026-02-25 21:59:29 +08:00
|
|
|
if servers.is_empty() {
|
2026-04-14 22:51:02 +08:00
|
|
|
self.server_tabs = vec![ServerTab::new()];
|
2026-02-25 21:59:29 +08:00
|
|
|
} else {
|
|
|
|
|
self.server_tabs = servers.iter().map(ServerTab::from_entry).collect();
|
|
|
|
|
}
|
|
|
|
|
self.active_tab = 0;
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
self.scroll_offset = 0;
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
fn get_global(&self, key: &str) -> Option<String> {
|
|
|
|
|
self.global_fields
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.key == key)
|
|
|
|
|
.map(|f| f.value.clone())
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
fn get_tab(tab: &ServerTab, key: &str) -> Option<String> {
|
|
|
|
|
tab.fields
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.key == key)
|
|
|
|
|
.map(|f| f.value.clone())
|
|
|
|
|
.filter(|v| !v.is_empty())
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
fn toggle_enabled(&self, key: &str) -> bool {
|
|
|
|
|
self.get_global(key).as_deref() == Some("true")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_required_fields(&self) -> anyhow::Result<()> {
|
|
|
|
|
for (tab_idx, tab) in self.server_tabs.iter().enumerate() {
|
|
|
|
|
for field in &tab.fields {
|
|
|
|
|
if field.required && field.value.trim().is_empty() {
|
|
|
|
|
anyhow::bail!("server {} field `{}` is required", tab_idx + 1, field.label);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for field in &self.global_fields {
|
|
|
|
|
if field.required && field.value.trim().is_empty() {
|
|
|
|
|
anyhow::bail!("field `{}` is required", field.label);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_optional_heartbeat_interval(&self) -> anyhow::Result<Option<u64>> {
|
|
|
|
|
let Some(raw) = self.get_global("heartbeat_interval") else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let value = raw.trim().parse::<u64>().map_err(|_| {
|
|
|
|
|
anyhow::anyhow!("heartbeat interval must be an integer number of seconds")
|
|
|
|
|
})?;
|
|
|
|
|
if value == 0 || value > 3600 {
|
|
|
|
|
anyhow::bail!("heartbeat interval must be between 1 and 3600 seconds");
|
|
|
|
|
}
|
|
|
|
|
Ok(Some(value))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:01:18 +08:00
|
|
|
fn parse_optional_upstream_proxy_url(&self) -> anyhow::Result<Option<String>> {
|
2026-05-08 22:03:17 +08:00
|
|
|
let Some(raw) = self.get_global("upstream_proxy_url") else {
|
2026-05-07 15:01:18 +08:00
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let trimmed = raw.trim();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
UpstreamProxyConfig::parse(trimmed)
|
2026-05-08 22:03:17 +08:00
|
|
|
.map_err(|err| anyhow::anyhow!("egress proxy URL invalid: {err}"))?;
|
2026-05-07 15:01:18 +08:00
|
|
|
Ok(Some(trimmed.to_string()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
fn default_file_log_dir(&self) -> String {
|
|
|
|
|
if self.toggle_enabled("install_service") {
|
2026-05-20 01:02:01 +08:00
|
|
|
return "/var/log/aether-tunnel".to_string();
|
2026-04-12 16:02:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let base = self
|
|
|
|
|
.config_path
|
|
|
|
|
.parent()
|
|
|
|
|
.filter(|path| !path.as_os_str().is_empty())
|
|
|
|
|
.unwrap_or_else(|| Path::new("."));
|
|
|
|
|
if base == Path::new(".") {
|
|
|
|
|
"logs".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
base.join("logs").display().to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_config(&self) -> anyhow::Result<ConfigFile> {
|
|
|
|
|
self.validate_required_fields()?;
|
|
|
|
|
|
|
|
|
|
let get_global = |key: &str| -> Option<String> { self.get_global(key) };
|
|
|
|
|
|
|
|
|
|
let get_tab = |tab: &ServerTab, key: &str| -> Option<String> { Self::get_tab(tab, key) };
|
|
|
|
|
|
|
|
|
|
let save_logs_to_file = self.toggle_enabled("save_logs_to_file");
|
2026-02-25 21:59:29 +08:00
|
|
|
let mut cfg = ConfigFile {
|
|
|
|
|
log_level: get_global("log_level"),
|
2026-04-15 00:31:36 +08:00
|
|
|
allow_private_targets: Some(self.toggle_enabled("allow_private_targets")),
|
2026-04-12 16:02:38 +08:00
|
|
|
heartbeat_interval: self.parse_optional_heartbeat_interval()?,
|
2026-05-07 15:01:18 +08:00
|
|
|
upstream_proxy_url: self.parse_optional_upstream_proxy_url()?,
|
2026-04-12 16:02:38 +08:00
|
|
|
log_destination: Some(if save_logs_to_file {
|
2026-05-20 01:02:01 +08:00
|
|
|
TunnelLogDestinationArg::Both
|
2026-04-12 16:02:38 +08:00
|
|
|
} else {
|
2026-05-20 01:02:01 +08:00
|
|
|
TunnelLogDestinationArg::Stdout
|
2026-04-12 16:02:38 +08:00
|
|
|
}),
|
|
|
|
|
log_dir: save_logs_to_file.then(|| self.default_file_log_dir()),
|
2026-05-20 01:02:01 +08:00
|
|
|
log_rotation: save_logs_to_file.then_some(TunnelLogRotationArg::Daily),
|
2026-04-12 16:02:38 +08:00
|
|
|
log_retention_days: save_logs_to_file.then_some(DEFAULT_LOG_RETENTION_DAYS),
|
|
|
|
|
log_max_files: save_logs_to_file.then_some(DEFAULT_LOG_MAX_FILES),
|
2026-02-25 21:59:29 +08:00
|
|
|
..ConfigFile::default()
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-14 22:51:02 +08:00
|
|
|
// Always write [[servers]] format.
|
2026-02-25 21:59:29 +08:00
|
|
|
cfg.servers = self
|
|
|
|
|
.server_tabs
|
|
|
|
|
.iter()
|
2026-05-21 16:07:56 +08:00
|
|
|
.map(|tab| {
|
|
|
|
|
let tunnel_security = get_tab(tab, "tunnel_security")
|
|
|
|
|
.map(|value| value.parse().map_err(anyhow::Error::msg))
|
|
|
|
|
.transpose()?;
|
|
|
|
|
Ok(ServerEntry {
|
|
|
|
|
aether_url: get_tab(tab, "aether_url").unwrap_or_default(),
|
|
|
|
|
management_token: get_tab(tab, "management_token").unwrap_or_default(),
|
|
|
|
|
node_name: get_tab(tab, "node_name"),
|
|
|
|
|
tunnel_security,
|
|
|
|
|
tunnel_encryption_key: get_tab(tab, "tunnel_encryption_key"),
|
|
|
|
|
})
|
2026-02-25 21:59:29 +08:00
|
|
|
})
|
2026-05-21 16:07:56 +08:00
|
|
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
|
|
|
|
for server in &cfg.servers {
|
|
|
|
|
if server.tunnel_security == Some(crate::config::TunnelSecurity::NonTlsRequired)
|
|
|
|
|
&& server
|
|
|
|
|
.tunnel_encryption_key
|
|
|
|
|
.as_deref()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.is_none()
|
|
|
|
|
{
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
"Tunnel Encryption Key is required when Tunnel Security is non_tls_required"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
Ok(cfg)
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save(&mut self) -> anyhow::Result<()> {
|
2026-04-12 16:02:38 +08:00
|
|
|
let cfg = self.to_config()?;
|
2026-02-07 19:20:09 +08:00
|
|
|
cfg.save(&self.config_path)?;
|
2026-02-28 01:32:28 +08:00
|
|
|
// Restrict config file permissions to owner-only (contains management token).
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
{
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
let _ =
|
|
|
|
|
std::fs::set_permissions(&self.config_path, std::fs::Permissions::from_mode(0o600));
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
self.modified = false;
|
|
|
|
|
self.saved_once = true;
|
|
|
|
|
self.message = Some((
|
2026-02-08 13:33:08 +08:00
|
|
|
format!("saved to {}", self.config_path.display()),
|
2026-02-07 19:20:09 +08:00
|
|
|
Instant::now(),
|
|
|
|
|
false,
|
|
|
|
|
));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Scrolling ---------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
fn ensure_visible(&mut self, visible_rows: usize) {
|
|
|
|
|
if visible_rows == 0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// Account for separator line between server and global fields
|
|
|
|
|
let display_row = if self.selected >= self.server_field_count() {
|
|
|
|
|
self.selected + 1
|
|
|
|
|
} else {
|
|
|
|
|
self.selected
|
|
|
|
|
};
|
|
|
|
|
if display_row < self.scroll_offset {
|
|
|
|
|
self.scroll_offset = display_row;
|
|
|
|
|
} else if display_row >= self.scroll_offset + visible_rows {
|
|
|
|
|
self.scroll_offset = display_row - visible_rows + 1;
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Key handling -------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
/// Returns `true` when the app should exit.
|
|
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
2026-02-08 16:10:13 +08:00
|
|
|
// Expire old messages (but keep quit-confirmation messages alive)
|
2026-02-07 19:20:09 +08:00
|
|
|
if let Some((_, when, _)) = &self.message {
|
2026-02-25 21:59:29 +08:00
|
|
|
if !self.pending_quit && !self.confirm_delete && when.elapsed() > Duration::from_secs(4)
|
|
|
|
|
{
|
2026-02-07 19:20:09 +08:00
|
|
|
self.message = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if key.code == KeyCode::Char('s')
|
|
|
|
|
&& (key.modifiers.contains(KeyModifiers::CONTROL)
|
|
|
|
|
|| key.modifiers.contains(KeyModifiers::SUPER))
|
|
|
|
|
{
|
|
|
|
|
if self.mode == Mode::Editing && !self.commit_edit_buffer() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = self.save() {
|
|
|
|
|
self.message = Some((format!("error: {}", e), Instant::now(), true));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
match self.mode {
|
|
|
|
|
Mode::Normal => self.handle_normal(key),
|
|
|
|
|
Mode::Editing => {
|
|
|
|
|
self.handle_edit(key);
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_normal(&mut self, key: KeyEvent) -> bool {
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Quit handling (with unsaved-changes confirmation) -----------------
|
2026-02-08 16:10:13 +08:00
|
|
|
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
|
|
|
|
|
|
|
|
|
|
if is_quit_key {
|
|
|
|
|
if !self.modified || self.pending_quit {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
self.pending_quit = true;
|
2026-02-25 21:59:29 +08:00
|
|
|
self.confirm_delete = false;
|
2026-02-08 16:10:13 +08:00
|
|
|
self.message = Some((
|
2026-04-12 16:02:38 +08:00
|
|
|
"unsaved changes! q again to discard, Ctrl+S to save and exit".into(),
|
2026-02-08 16:10:13 +08:00
|
|
|
Instant::now(),
|
|
|
|
|
true,
|
|
|
|
|
));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// Any other key cancels pending quit / pending delete
|
2026-02-08 16:10:13 +08:00
|
|
|
if self.pending_quit {
|
|
|
|
|
self.pending_quit = false;
|
|
|
|
|
self.message = None;
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
if self.confirm_delete
|
|
|
|
|
&& !matches!(
|
|
|
|
|
key.code,
|
|
|
|
|
KeyCode::Backspace | KeyCode::Delete | KeyCode::Char('x')
|
|
|
|
|
)
|
|
|
|
|
{
|
2026-02-25 21:59:29 +08:00
|
|
|
self.confirm_delete = false;
|
|
|
|
|
self.message = None;
|
|
|
|
|
}
|
2026-02-08 16:10:13 +08:00
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
|
|
|
self.selected = self.selected.saturating_sub(1);
|
|
|
|
|
}
|
2026-04-17 01:43:45 +08:00
|
|
|
KeyCode::Down | KeyCode::Char('j') if self.selected + 1 < self.total_field_count() => {
|
2026-04-17 01:12:43 +08:00
|
|
|
self.selected += 1;
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
KeyCode::Home => self.selected = 0,
|
2026-02-25 21:59:29 +08:00
|
|
|
KeyCode::End => self.selected = self.total_field_count() - 1,
|
2026-02-07 19:20:09 +08:00
|
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
2026-02-25 21:59:29 +08:00
|
|
|
let kind = self.selected_field().kind;
|
|
|
|
|
let key_str = self.selected_field().key;
|
|
|
|
|
let value = self.selected_field().value.clone();
|
|
|
|
|
match kind {
|
2026-02-07 19:20:09 +08:00
|
|
|
FieldKind::Bool => {
|
2026-02-25 21:59:29 +08:00
|
|
|
let toggled = if value == "true" { "false" } else { "true" };
|
|
|
|
|
if key_str == "install_service"
|
2026-02-08 13:33:08 +08:00
|
|
|
&& toggled == "true"
|
|
|
|
|
&& !super::service::is_available()
|
|
|
|
|
{
|
2026-04-13 17:20:22 +08:00
|
|
|
self.message =
|
|
|
|
|
Some((super::service::unavailable_hint(), Instant::now(), true));
|
2026-02-08 13:33:08 +08:00
|
|
|
} else {
|
2026-02-25 21:59:29 +08:00
|
|
|
self.selected_field_mut().value = toggled.into();
|
2026-02-08 13:33:08 +08:00
|
|
|
self.modified = true;
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
FieldKind::LogLevel => {
|
2026-02-08 13:33:08 +08:00
|
|
|
const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
|
2026-02-25 21:59:29 +08:00
|
|
|
let idx = LEVELS.iter().position(|l| *l == value).unwrap_or(2);
|
|
|
|
|
self.selected_field_mut().value = LEVELS[(idx + 1) % LEVELS.len()].into();
|
2026-02-07 19:20:09 +08:00
|
|
|
self.modified = true;
|
|
|
|
|
}
|
|
|
|
|
_ => {
|
2026-02-25 21:59:29 +08:00
|
|
|
self.edit_buffer = value;
|
2026-02-07 19:20:09 +08:00
|
|
|
self.edit_cursor = self.edit_buffer.chars().count();
|
|
|
|
|
self.mode = Mode::Editing;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Tab navigation --
|
2026-04-17 01:12:43 +08:00
|
|
|
KeyCode::Tab | KeyCode::Right if self.server_tabs.len() > 1 => {
|
|
|
|
|
self.active_tab = (self.active_tab + 1) % self.server_tabs.len();
|
|
|
|
|
self.clamp_selection();
|
2026-02-25 21:59:29 +08:00
|
|
|
}
|
2026-04-17 01:12:43 +08:00
|
|
|
KeyCode::BackTab | KeyCode::Left if self.server_tabs.len() > 1 => {
|
|
|
|
|
self.active_tab = if self.active_tab == 0 {
|
|
|
|
|
self.server_tabs.len() - 1
|
|
|
|
|
} else {
|
|
|
|
|
self.active_tab - 1
|
|
|
|
|
};
|
|
|
|
|
self.clamp_selection();
|
2026-02-25 21:59:29 +08:00
|
|
|
}
|
|
|
|
|
KeyCode::Char(c @ '1'..='9') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
let idx = (c as usize) - ('1' as usize);
|
|
|
|
|
if idx < self.server_tabs.len() && idx != self.active_tab {
|
|
|
|
|
self.active_tab = idx;
|
|
|
|
|
self.clamp_selection();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// -- Add / remove server --
|
|
|
|
|
KeyCode::Char('+') | KeyCode::Char('a') => {
|
2026-05-08 22:03:17 +08:00
|
|
|
let tab = ServerTab::new();
|
2026-05-07 16:20:20 +08:00
|
|
|
self.server_tabs.push(tab);
|
2026-02-25 21:59:29 +08:00
|
|
|
self.active_tab = self.server_tabs.len() - 1;
|
|
|
|
|
self.selected = 0;
|
|
|
|
|
self.scroll_offset = 0;
|
|
|
|
|
self.modified = true;
|
|
|
|
|
self.message = Some((
|
|
|
|
|
format!("added server {}", self.server_tabs.len()),
|
|
|
|
|
Instant::now(),
|
|
|
|
|
false,
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
KeyCode::Backspace | KeyCode::Delete | KeyCode::Char('x') => {
|
2026-02-25 21:59:29 +08:00
|
|
|
if self.server_tabs.len() <= 1 {
|
|
|
|
|
self.message =
|
|
|
|
|
Some(("cannot remove the last server".into(), Instant::now(), true));
|
|
|
|
|
} else if self.confirm_delete {
|
|
|
|
|
let removed = self.active_tab + 1;
|
|
|
|
|
self.server_tabs.remove(self.active_tab);
|
|
|
|
|
self.active_tab = self.active_tab.min(self.server_tabs.len() - 1);
|
|
|
|
|
self.clamp_selection();
|
|
|
|
|
self.modified = true;
|
|
|
|
|
self.message =
|
|
|
|
|
Some((format!("server {} removed", removed), Instant::now(), false));
|
|
|
|
|
} else {
|
|
|
|
|
self.confirm_delete = true;
|
|
|
|
|
self.message = Some((
|
2026-04-12 16:02:38 +08:00
|
|
|
"press Backspace again to remove this server".into(),
|
2026-02-25 21:59:29 +08:00
|
|
|
Instant::now(),
|
|
|
|
|
true,
|
|
|
|
|
));
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_edit(&mut self, key: KeyEvent) {
|
|
|
|
|
match key.code {
|
|
|
|
|
KeyCode::Esc => {
|
|
|
|
|
self.mode = Mode::Normal;
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
2026-04-12 16:02:38 +08:00
|
|
|
self.commit_edit_buffer();
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
2026-04-17 01:12:43 +08:00
|
|
|
KeyCode::Backspace if self.edit_cursor > 0 => {
|
|
|
|
|
self.edit_cursor -= 1;
|
|
|
|
|
let byte = self.char_byte_pos(self.edit_cursor);
|
|
|
|
|
self.edit_buffer.remove(byte);
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
2026-04-17 01:43:45 +08:00
|
|
|
KeyCode::Delete if self.edit_cursor < self.edit_buffer.chars().count() => {
|
|
|
|
|
let byte = self.char_byte_pos(self.edit_cursor);
|
|
|
|
|
self.edit_buffer.remove(byte);
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
KeyCode::Left => {
|
|
|
|
|
self.edit_cursor = self.edit_cursor.saturating_sub(1);
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Right => {
|
|
|
|
|
let len = self.edit_buffer.chars().count();
|
|
|
|
|
if self.edit_cursor < len {
|
|
|
|
|
self.edit_cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Home => self.edit_cursor = 0,
|
|
|
|
|
KeyCode::End => self.edit_cursor = self.edit_buffer.chars().count(),
|
|
|
|
|
KeyCode::Char(c) => {
|
|
|
|
|
let byte = self.char_byte_pos(self.edit_cursor);
|
|
|
|
|
self.edit_buffer.insert(byte, c);
|
|
|
|
|
self.edit_cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
fn commit_edit_buffer(&mut self) -> bool {
|
|
|
|
|
if self.validate_edit() {
|
2026-05-08 22:03:17 +08:00
|
|
|
self.selected_field_mut().value = self.edit_buffer.clone();
|
2026-04-12 16:02:38 +08:00
|
|
|
self.modified = true;
|
|
|
|
|
self.mode = Mode::Normal;
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
self.message = Some(("invalid format".into(), Instant::now(), true));
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
fn validate_edit(&self) -> bool {
|
2026-04-12 16:02:38 +08:00
|
|
|
let key = self.selected_field().key;
|
|
|
|
|
let trimmed = self.edit_buffer.trim();
|
|
|
|
|
|
|
|
|
|
if self.selected_field().required && trimmed.is_empty() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match key {
|
|
|
|
|
"heartbeat_interval" => {
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
match trimmed.parse::<u64>() {
|
|
|
|
|
Ok(value) => (1..=3600).contains(&value),
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 15:01:18 +08:00
|
|
|
"upstream_proxy_url" => {
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
UpstreamProxyConfig::parse(trimmed).is_ok()
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
_ => true,
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Byte offset of the char at `char_idx`.
|
|
|
|
|
fn char_byte_pos(&self, char_idx: usize) -> usize {
|
|
|
|
|
self.edit_buffer
|
|
|
|
|
.char_indices()
|
|
|
|
|
.nth(char_idx)
|
|
|
|
|
.map(|(i, _)| i)
|
|
|
|
|
.unwrap_or(self.edit_buffer.len())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Rendering ----------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
fn ui(f: &mut Frame, app: &mut App) {
|
|
|
|
|
let area = f.area();
|
|
|
|
|
|
|
|
|
|
let title = if app.modified {
|
2026-05-20 01:02:01 +08:00
|
|
|
" Aether Tunnel Setup [*] "
|
2026-02-07 19:20:09 +08:00
|
|
|
} else {
|
2026-05-20 01:02:01 +08:00
|
|
|
" Aether Tunnel Setup "
|
2026-02-07 19:20:09 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let outer = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.title(title)
|
|
|
|
|
.title_alignment(ratatui::layout::Alignment::Center)
|
|
|
|
|
.border_style(Style::default().fg(Color::Cyan));
|
|
|
|
|
|
|
|
|
|
let inner = outer.inner(area);
|
|
|
|
|
f.render_widget(outer, area);
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// Split: fields | tab bar | footer
|
|
|
|
|
let chunks = Layout::vertical([
|
|
|
|
|
Constraint::Min(1),
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
Constraint::Length(4),
|
|
|
|
|
])
|
|
|
|
|
.split(inner);
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
render_fields(f, app, chunks[0]);
|
|
|
|
|
render_tab_bar(f, app, chunks[1]);
|
|
|
|
|
render_footer(f, app, chunks[2]);
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
|
|
|
|
|
let visible = area.height as usize;
|
|
|
|
|
app.ensure_visible(visible);
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
let server_count = app.server_field_count();
|
2026-02-07 19:20:09 +08:00
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
2026-02-25 21:59:29 +08:00
|
|
|
// display_row tracks the actual row index (including separator)
|
|
|
|
|
let mut display_row: usize = 0;
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// Server fields
|
|
|
|
|
for i in 0..server_count {
|
|
|
|
|
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
|
|
|
|
lines.push(build_field_line(app, i, display_row));
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
display_row += 1;
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// Separator line
|
|
|
|
|
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
" ----------------------------------------",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
display_row += 1;
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// Global fields
|
|
|
|
|
for i in 0..app.global_fields.len() {
|
|
|
|
|
let field_idx = server_count + i;
|
|
|
|
|
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
|
|
|
|
|
lines.push(build_field_line(app, field_idx, display_row));
|
|
|
|
|
}
|
|
|
|
|
display_row += 1;
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(lines);
|
|
|
|
|
f.render_widget(paragraph, area);
|
|
|
|
|
|
|
|
|
|
// Cursor position while editing
|
|
|
|
|
if app.mode == Mode::Editing {
|
2026-02-25 21:59:29 +08:00
|
|
|
let sel_display_row = if app.selected >= server_count {
|
|
|
|
|
app.selected + 1
|
|
|
|
|
} else {
|
|
|
|
|
app.selected
|
|
|
|
|
};
|
|
|
|
|
let row_in_view = sel_display_row.saturating_sub(app.scroll_offset);
|
2026-02-07 19:20:09 +08:00
|
|
|
let prefix: u16 = 3 + LABEL_WIDTH as u16 + 2;
|
|
|
|
|
let cx = area.x + prefix + app.edit_cursor as u16;
|
|
|
|
|
let cy = area.y + row_in_view as u16;
|
|
|
|
|
if cx < area.x + area.width && cy < area.y + area.height {
|
|
|
|
|
f.set_cursor_position((cx, cy));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
fn build_field_line(app: &App, field_idx: usize, _display_row: usize) -> Line<'static> {
|
|
|
|
|
let sc = app.server_field_count();
|
|
|
|
|
let field = if field_idx < sc {
|
|
|
|
|
&app.server_tabs[app.active_tab].fields[field_idx]
|
|
|
|
|
} else {
|
|
|
|
|
&app.global_fields[field_idx - sc]
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let selected = field_idx == app.selected;
|
|
|
|
|
let indicator = if selected { " > " } else { " " };
|
|
|
|
|
|
|
|
|
|
let label_style = if selected {
|
|
|
|
|
Style::default()
|
|
|
|
|
.fg(Color::Cyan)
|
|
|
|
|
.add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(Color::DarkGray)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let padded_label = format!("{:<width$}", field.label, width = LABEL_WIDTH);
|
|
|
|
|
|
|
|
|
|
let (value_text, value_style) = if app.mode == Mode::Editing && selected {
|
|
|
|
|
(app.edit_buffer.clone(), Style::default().fg(Color::Yellow))
|
|
|
|
|
} else {
|
|
|
|
|
field_display(field)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Line::from(vec![
|
|
|
|
|
Span::styled(indicator.to_string(), label_style),
|
|
|
|
|
Span::styled(padded_label, label_style),
|
|
|
|
|
Span::raw(" "),
|
|
|
|
|
Span::styled(value_text, value_style),
|
|
|
|
|
])
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
/// Returns (display_text, style) for a field in normal mode.
|
|
|
|
|
fn field_display(field: &Field) -> (String, Style) {
|
|
|
|
|
if field.value.is_empty() {
|
|
|
|
|
let text = if field.required {
|
2026-02-08 13:33:08 +08:00
|
|
|
"(required)".into()
|
2026-02-07 19:20:09 +08:00
|
|
|
} else {
|
2026-02-08 13:33:08 +08:00
|
|
|
"-".into()
|
2026-02-07 19:20:09 +08:00
|
|
|
};
|
|
|
|
|
let color = if field.required {
|
|
|
|
|
Color::Red
|
|
|
|
|
} else {
|
|
|
|
|
Color::DarkGray
|
|
|
|
|
};
|
|
|
|
|
return (text, Style::default().fg(color));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match field.kind {
|
|
|
|
|
FieldKind::Secret => (
|
2026-02-08 13:33:08 +08:00
|
|
|
"*".repeat(field.value.len().min(20)),
|
2026-02-07 19:20:09 +08:00
|
|
|
Style::default().fg(Color::White),
|
|
|
|
|
),
|
|
|
|
|
FieldKind::Bool => {
|
|
|
|
|
if field.value == "true" {
|
2026-02-08 13:33:08 +08:00
|
|
|
("[x] on".into(), Style::default().fg(Color::Green))
|
2026-02-07 19:20:09 +08:00
|
|
|
} else {
|
2026-02-08 13:33:08 +08:00
|
|
|
("[ ] off".into(), Style::default().fg(Color::DarkGray))
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
FieldKind::LogLevel => {
|
|
|
|
|
let color = match field.value.as_str() {
|
|
|
|
|
"trace" => Color::Magenta,
|
|
|
|
|
"debug" => Color::Blue,
|
|
|
|
|
"info" => Color::Green,
|
|
|
|
|
"warn" => Color::Yellow,
|
|
|
|
|
"error" => Color::Red,
|
|
|
|
|
_ => Color::White,
|
|
|
|
|
};
|
|
|
|
|
(field.value.clone(), Style::default().fg(color))
|
|
|
|
|
}
|
|
|
|
|
_ => (field.value.clone(), Style::default().fg(Color::White)),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
fn render_tab_bar(f: &mut Frame, app: &App, area: Rect) {
|
|
|
|
|
let mut spans: Vec<Span> = Vec::new();
|
|
|
|
|
spans.push(Span::raw(" "));
|
|
|
|
|
|
|
|
|
|
for (i, tab) in app.server_tabs.iter().enumerate() {
|
|
|
|
|
let num = i + 1;
|
|
|
|
|
let name = tab
|
|
|
|
|
.fields
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.key == "node_name")
|
|
|
|
|
.filter(|f| !f.value.is_empty())
|
|
|
|
|
.map(|f| f.value.clone())
|
|
|
|
|
.unwrap_or_else(|| format!("Server {}", num));
|
|
|
|
|
|
|
|
|
|
let label = format!(" {} {} ", num, name);
|
|
|
|
|
|
|
|
|
|
if i == app.active_tab {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
label,
|
|
|
|
|
Style::default()
|
|
|
|
|
.fg(Color::Black)
|
|
|
|
|
.bg(Color::Cyan)
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
));
|
|
|
|
|
} else {
|
|
|
|
|
spans.push(Span::styled(label, Style::default().fg(Color::DarkGray)));
|
|
|
|
|
}
|
|
|
|
|
spans.push(Span::raw(" "));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
spans.push(Span::styled(" + Add ", Style::default().fg(Color::Green)));
|
2026-04-12 16:02:38 +08:00
|
|
|
if app.server_tabs.len() > 1 {
|
|
|
|
|
spans.push(Span::raw(" "));
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
" Backspace Remove ",
|
|
|
|
|
Style::default().fg(Color::Yellow),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
|
|
|
|
|
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
|
2026-02-25 21:59:29 +08:00
|
|
|
let help = app.selected_field().help;
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
|
|
|
let keybindings = if app.mode == Mode::Editing {
|
2026-04-12 16:02:38 +08:00
|
|
|
"Enter 确认 Esc 取消 Ctrl+S 保存退出 Ctrl+C 退出"
|
2026-02-25 21:59:29 +08:00
|
|
|
} else if app.server_tabs.len() > 1 {
|
2026-04-12 16:02:38 +08:00
|
|
|
"↑/↓ 选择 ←/→ 切换服务器 Enter 编辑 + 新增 Backspace 删除 Ctrl+S 保存退出 Ctrl+C 退出"
|
2026-02-07 19:20:09 +08:00
|
|
|
} else {
|
2026-04-12 16:02:38 +08:00
|
|
|
"↑/↓ 选择 Enter 编辑 + 新增服务器 Ctrl+S 保存退出 Ctrl+C 退出"
|
2026-02-07 19:20:09 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut status_spans: Vec<Span> = vec![Span::styled(
|
2026-02-25 21:59:29 +08:00
|
|
|
format!(" {}", keybindings),
|
2026-02-07 19:20:09 +08:00
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)];
|
|
|
|
|
|
|
|
|
|
if let Some((msg, _, is_err)) = &app.message {
|
|
|
|
|
let color = if *is_err { Color::Red } else { Color::Green };
|
|
|
|
|
status_spans.push(Span::raw(" "));
|
|
|
|
|
status_spans.push(Span::styled(msg.clone(), Style::default().fg(color)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let footer_text = vec![
|
|
|
|
|
Line::raw(""),
|
|
|
|
|
Line::from(Span::styled(
|
|
|
|
|
format!(" {}", help),
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)),
|
2026-02-25 21:59:29 +08:00
|
|
|
Line::from(status_spans),
|
2026-02-07 19:20:09 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let footer = Paragraph::new(footer_text).block(
|
|
|
|
|
Block::default()
|
|
|
|
|
.borders(Borders::TOP)
|
|
|
|
|
.border_style(Style::default().fg(Color::DarkGray)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
f.render_widget(footer, area);
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Entry point --------------------------------------------------------------
|
2026-02-07 19:20:09 +08:00
|
|
|
|
2026-02-08 16:10:13 +08:00
|
|
|
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
|
2026-02-07 19:20:09 +08:00
|
|
|
terminal::enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
|
|
|
|
|
let mut app = App::new(config_path.clone());
|
|
|
|
|
app.load_from_file();
|
|
|
|
|
|
|
|
|
|
let result = event_loop(&mut terminal, &mut app);
|
|
|
|
|
|
|
|
|
|
terminal::disable_raw_mode()?;
|
|
|
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
|
|
|
terminal.show_cursor()?;
|
|
|
|
|
|
|
|
|
|
result?;
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
// -- Post-TUI: decide outcome ---------------------------------------------
|
2026-02-08 13:33:08 +08:00
|
|
|
|
2026-02-08 16:10:13 +08:00
|
|
|
if !app.saved_once {
|
|
|
|
|
return Ok(SetupOutcome::Cancelled);
|
|
|
|
|
}
|
2026-02-08 13:33:08 +08:00
|
|
|
|
2026-02-08 16:10:13 +08:00
|
|
|
eprintln!();
|
|
|
|
|
eprintln!(" Config saved to {}", config_path.display());
|
|
|
|
|
eprintln!();
|
|
|
|
|
|
|
|
|
|
let wants_service = app
|
2026-02-25 21:59:29 +08:00
|
|
|
.global_fields
|
2026-02-08 16:10:13 +08:00
|
|
|
.iter()
|
|
|
|
|
.find(|f| f.key == "install_service")
|
|
|
|
|
.map(|f| f.value == "true")
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
if wants_service {
|
|
|
|
|
match super::service::install_service(&config_path) {
|
|
|
|
|
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!(" Service install failed: {}", e);
|
2026-05-20 01:02:01 +08:00
|
|
|
eprintln!(" Starting tunnel directly instead.\n");
|
2026-02-08 13:33:08 +08:00
|
|
|
}
|
2026-02-08 16:10:13 +08:00
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
} else if super::service::is_installed() {
|
|
|
|
|
if let Err(e) = super::service::uninstall_service() {
|
|
|
|
|
eprintln!(" Service uninstall failed: {}", e);
|
|
|
|
|
eprintln!();
|
2026-02-08 13:33:08 +08:00
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:10:13 +08:00
|
|
|
Ok(SetupOutcome::ReadyToRun(config_path))
|
2026-02-07 19:20:09 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn event_loop(
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
app: &mut App,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
loop {
|
|
|
|
|
terminal.draw(|f| ui(f, app))?;
|
|
|
|
|
|
|
|
|
|
if event::poll(Duration::from_millis(200))? {
|
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
|
|
|
if key.kind == KeyEventKind::Press && app.handle_key(key) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn set_server_field(app: &mut App, key: &str, value: &str) {
|
|
|
|
|
let field = app.server_tabs[0]
|
|
|
|
|
.fields
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.find(|field| field.key == key)
|
|
|
|
|
.expect("server field");
|
|
|
|
|
field.value = value.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_global_field(app: &mut App, key: &str, value: &str) {
|
|
|
|
|
let field = app
|
|
|
|
|
.global_fields
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.find(|field| field.key == key)
|
|
|
|
|
.expect("global field");
|
|
|
|
|
field.value = value.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sample_app() -> App {
|
2026-05-20 01:02:01 +08:00
|
|
|
let mut app = App::new(PathBuf::from("aether-tunnel.toml"));
|
2026-04-12 16:02:38 +08:00
|
|
|
set_server_field(&mut app, "aether_url", "https://aether.example.com");
|
|
|
|
|
set_server_field(&mut app, "management_token", "ae_test");
|
|
|
|
|
set_server_field(&mut app, "node_name", "jp-proxy-01");
|
|
|
|
|
app
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unique_temp_config_path(name: &str) -> PathBuf {
|
|
|
|
|
let nanos = std::time::SystemTime::now()
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
.expect("clock should work")
|
|
|
|
|
.as_nanos();
|
2026-05-20 01:02:01 +08:00
|
|
|
std::env::temp_dir().join(format!("aether-tunnel-{name}-{nanos}.toml"))
|
2026-04-12 16:02:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn new_app_shows_default_heartbeat_interval() {
|
|
|
|
|
let app = sample_app();
|
|
|
|
|
let heartbeat = app
|
|
|
|
|
.global_fields
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|field| field.key == "heartbeat_interval")
|
|
|
|
|
.expect("heartbeat field");
|
|
|
|
|
assert_eq!(heartbeat.value, DEFAULT_HEARTBEAT_INTERVAL_SECS.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 16:20:20 +08:00
|
|
|
#[test]
|
2026-05-08 22:03:17 +08:00
|
|
|
fn new_app_places_egress_proxy_in_global_fields() {
|
2026-05-07 16:20:20 +08:00
|
|
|
let app = sample_app();
|
2026-05-08 22:03:17 +08:00
|
|
|
let server_keys: Vec<&str> = app.server_tabs[0]
|
2026-05-07 16:20:20 +08:00
|
|
|
.fields
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|field| field.key)
|
|
|
|
|
.collect();
|
2026-05-08 22:03:17 +08:00
|
|
|
let global_keys: Vec<&str> = app.global_fields.iter().map(|field| field.key).collect();
|
2026-05-07 16:20:20 +08:00
|
|
|
|
|
|
|
|
assert_eq!(
|
2026-05-08 22:03:17 +08:00
|
|
|
server_keys,
|
2026-05-21 16:07:56 +08:00
|
|
|
vec![
|
|
|
|
|
"aether_url",
|
|
|
|
|
"management_token",
|
|
|
|
|
"node_name",
|
|
|
|
|
"tunnel_security",
|
|
|
|
|
"tunnel_encryption_key"
|
|
|
|
|
]
|
2026-05-07 16:20:20 +08:00
|
|
|
);
|
2026-05-08 22:03:17 +08:00
|
|
|
assert_eq!(global_keys.first().copied(), Some("upstream_proxy_url"));
|
2026-05-07 16:20:20 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 16:07:56 +08:00
|
|
|
#[test]
|
|
|
|
|
fn to_config_persists_tunnel_security_fields_per_server() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
set_server_field(&mut app, "tunnel_security", "non_tls_required");
|
|
|
|
|
set_server_field(&mut app, "tunnel_encryption_key", "base64-32-bytes");
|
|
|
|
|
|
|
|
|
|
let cfg = app.to_config().expect("config should serialize");
|
|
|
|
|
assert_eq!(cfg.servers.len(), 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cfg.servers[0].tunnel_security,
|
|
|
|
|
Some(crate::config::TunnelSecurity::NonTlsRequired)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cfg.servers[0].tunnel_encryption_key.as_deref(),
|
|
|
|
|
Some("base64-32-bytes")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 15:20:36 +08:00
|
|
|
#[test]
|
|
|
|
|
fn to_config_omits_blank_tunnel_security_for_auto_inference() {
|
|
|
|
|
let app = sample_app();
|
|
|
|
|
|
|
|
|
|
let cfg = app.to_config().expect("config should serialize");
|
|
|
|
|
assert_eq!(cfg.servers.len(), 1);
|
|
|
|
|
assert_eq!(cfg.servers[0].tunnel_security, None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 16:07:56 +08:00
|
|
|
#[test]
|
|
|
|
|
fn to_config_rejects_non_tls_security_without_key() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
set_server_field(&mut app, "tunnel_security", "non_tls_required");
|
|
|
|
|
|
|
|
|
|
let error = app
|
|
|
|
|
.to_config()
|
|
|
|
|
.expect_err("secure non-TLS mode should require a key");
|
|
|
|
|
assert!(error.to_string().contains("Tunnel Encryption Key"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn to_config_enables_pretty_file_logging_with_defaults() {
|
|
|
|
|
let mut app = sample_app();
|
2026-07-29 21:55:43 +08:00
|
|
|
set_global_field(&mut app, "install_service", "false");
|
2026-04-12 16:02:38 +08:00
|
|
|
set_global_field(&mut app, "save_logs_to_file", "true");
|
|
|
|
|
|
|
|
|
|
let cfg = app.to_config().expect("config should serialize");
|
2026-05-20 01:02:01 +08:00
|
|
|
assert_eq!(cfg.log_destination, Some(TunnelLogDestinationArg::Both));
|
2026-04-12 16:02:38 +08:00
|
|
|
assert_eq!(cfg.log_dir.as_deref(), Some("logs"));
|
2026-05-20 01:02:01 +08:00
|
|
|
assert_eq!(cfg.log_rotation, Some(TunnelLogRotationArg::Daily));
|
2026-04-12 16:02:38 +08:00
|
|
|
assert_eq!(cfg.log_retention_days, Some(DEFAULT_LOG_RETENTION_DAYS));
|
|
|
|
|
assert_eq!(cfg.log_max_files, Some(DEFAULT_LOG_MAX_FILES));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_config_uses_service_log_dir_when_installing_service() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
set_global_field(&mut app, "install_service", "true");
|
|
|
|
|
set_global_field(&mut app, "save_logs_to_file", "true");
|
|
|
|
|
|
|
|
|
|
let cfg = app.to_config().expect("config should serialize");
|
2026-05-20 01:02:01 +08:00
|
|
|
assert_eq!(cfg.log_dir.as_deref(), Some("/var/log/aether-tunnel"));
|
2026-04-12 16:02:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_config_persists_optional_heartbeat_interval() {
|
|
|
|
|
let mut app = sample_app();
|
2026-04-15 00:31:36 +08:00
|
|
|
set_global_field(&mut app, "allow_private_targets", "true");
|
2026-04-12 16:02:38 +08:00
|
|
|
set_global_field(&mut app, "heartbeat_interval", "45");
|
2026-05-08 22:03:17 +08:00
|
|
|
set_global_field(&mut app, "upstream_proxy_url", "socks5h://127.0.0.1:1080");
|
2026-04-12 16:02:38 +08:00
|
|
|
|
|
|
|
|
let cfg = app.to_config().expect("config should serialize");
|
2026-04-15 00:31:36 +08:00
|
|
|
assert_eq!(cfg.allow_private_targets, Some(true));
|
2026-04-12 16:02:38 +08:00
|
|
|
assert_eq!(cfg.heartbeat_interval, Some(45));
|
2026-05-07 15:01:18 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
cfg.upstream_proxy_url.as_deref(),
|
|
|
|
|
Some("socks5h://127.0.0.1:1080")
|
|
|
|
|
);
|
2026-04-12 16:02:38 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_config_rejects_invalid_heartbeat_interval() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
set_global_field(&mut app, "heartbeat_interval", "0");
|
|
|
|
|
|
|
|
|
|
let error = app.to_config().expect_err("heartbeat 0 should be rejected");
|
|
|
|
|
assert!(error.to_string().contains("heartbeat interval"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 15:01:18 +08:00
|
|
|
#[test]
|
2026-05-08 22:03:17 +08:00
|
|
|
fn to_config_rejects_invalid_egress_proxy_url() {
|
2026-05-07 15:01:18 +08:00
|
|
|
let mut app = sample_app();
|
2026-05-08 22:03:17 +08:00
|
|
|
set_global_field(&mut app, "upstream_proxy_url", "ftp://proxy.example");
|
2026-05-07 15:01:18 +08:00
|
|
|
|
|
|
|
|
let error = app
|
|
|
|
|
.to_config()
|
2026-05-08 22:03:17 +08:00
|
|
|
.expect_err("invalid egress proxy should be rejected");
|
|
|
|
|
assert!(error.to_string().contains("egress proxy URL"));
|
2026-05-07 15:01:18 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
#[test]
|
|
|
|
|
fn to_config_rejects_missing_required_node_name() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
set_server_field(&mut app, "node_name", "");
|
|
|
|
|
|
|
|
|
|
let error = app
|
|
|
|
|
.to_config()
|
|
|
|
|
.expect_err("missing node_name should be rejected");
|
|
|
|
|
assert!(error.to_string().contains("Node Name"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ctrl_c_exits_immediately_even_with_unsaved_changes() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
app.modified = true;
|
|
|
|
|
|
|
|
|
|
assert!(app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)));
|
|
|
|
|
assert!(!app.saved_once);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ctrl_s_commits_edit_saves_and_exits() {
|
|
|
|
|
let config_path = unique_temp_config_path("ctrl-s");
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
app.config_path = config_path.clone();
|
2026-04-15 00:31:36 +08:00
|
|
|
let heartbeat_idx = app
|
|
|
|
|
.global_fields
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|field| field.key == "heartbeat_interval")
|
|
|
|
|
.expect("heartbeat field");
|
|
|
|
|
app.selected = app.server_field_count() + heartbeat_idx;
|
2026-04-12 16:02:38 +08:00
|
|
|
app.mode = Mode::Editing;
|
|
|
|
|
app.edit_buffer = "45".to_string();
|
|
|
|
|
app.edit_cursor = 2;
|
|
|
|
|
|
|
|
|
|
assert!(app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL,)));
|
|
|
|
|
assert!(app.saved_once);
|
|
|
|
|
assert!(matches!(app.mode, Mode::Normal));
|
|
|
|
|
|
|
|
|
|
let saved = fs::read_to_string(&config_path).expect("config should be saved");
|
|
|
|
|
assert!(saved.contains("heartbeat_interval = 45"));
|
|
|
|
|
|
|
|
|
|
let _ = fs::remove_file(config_path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn backspace_confirms_and_removes_active_server_tab() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
app.server_tabs.push(ServerTab::from_entry(&ServerEntry {
|
|
|
|
|
aether_url: "https://aether-2.example.com".to_string(),
|
|
|
|
|
management_token: "ae_test_2".to_string(),
|
|
|
|
|
node_name: Some("jp-proxy-02".to_string()),
|
2026-05-21 16:07:56 +08:00
|
|
|
tunnel_security: None,
|
|
|
|
|
tunnel_encryption_key: None,
|
2026-04-12 16:02:38 +08:00
|
|
|
}));
|
|
|
|
|
app.active_tab = 1;
|
|
|
|
|
|
|
|
|
|
assert!(!app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE,)));
|
|
|
|
|
assert!(app.confirm_delete);
|
|
|
|
|
assert_eq!(app.server_tabs.len(), 2);
|
|
|
|
|
|
|
|
|
|
assert!(!app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE,)));
|
|
|
|
|
assert_eq!(app.server_tabs.len(), 1);
|
|
|
|
|
assert_eq!(app.active_tab, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn left_and_right_switch_between_server_tabs() {
|
|
|
|
|
let mut app = sample_app();
|
|
|
|
|
app.server_tabs.push(ServerTab::from_entry(&ServerEntry {
|
|
|
|
|
aether_url: "https://aether-2.example.com".to_string(),
|
|
|
|
|
management_token: "ae_test_2".to_string(),
|
|
|
|
|
node_name: Some("jp-proxy-02".to_string()),
|
2026-05-21 16:07:56 +08:00
|
|
|
tunnel_security: None,
|
|
|
|
|
tunnel_encryption_key: None,
|
2026-04-12 16:02:38 +08:00
|
|
|
}));
|
|
|
|
|
app.active_tab = 0;
|
|
|
|
|
|
|
|
|
|
assert!(!app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE,)));
|
|
|
|
|
assert_eq!(app.active_tab, 1);
|
|
|
|
|
|
|
|
|
|
assert!(!app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE,)));
|
|
|
|
|
assert_eq!(app.active_tab, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|