use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub port: u16, pub bind: String, pub scrollback: usize, pub auth_ttl_secs: u64, pub attach_ticket_ttl_secs: u64, pub allow_attach_tickets: bool, pub client_timeout_secs: u64, pub retransmit_window: usize, #[serde(default = "default_output_frame_interval_ms")] pub output_frame_interval_ms: u64, pub default_input_mode: String, pub prewarm_sessions: Vec, pub create_on_attach: bool, pub shell: String, pub sessions_dir: String, pub secret_path: String, #[serde(default = "default_true")] pub native_auth: bool, #[serde(default = "default_host_key")] pub host_key: String, #[serde(default = "default_authorized_keys")] pub authorized_keys: Vec, #[serde(default = "default_native_auth_rate_limit_per_minute")] pub native_auth_rate_limit_per_minute: u32, /// Rotate a client's transport traffic key after this many packets in the /// current epoch (spec §11). `0` disables the packet-count trigger. #[serde(default = "default_rekey_after_packets")] pub rekey_after_packets: u64, /// Rotate a client's transport traffic key after this many wall-clock seconds /// in the current epoch (spec §11). `0` disables the time trigger. #[serde(default = "default_rekey_after_secs")] pub rekey_after_secs: u64, #[serde(default = "default_true")] pub allow_tcp_forwarding: bool, #[serde(default)] pub allow_remote_forwarding: bool, #[serde(default)] pub allow_remote_non_loopback_bind: bool, #[serde(default)] pub allow_agent_forwarding: bool, #[serde(default = "default_true")] pub allow_file_transfer: bool, #[serde(default = "default_true")] pub allow_exec_command: bool, #[serde(default = "default_accept_env")] pub accept_env: Vec, /// Run terminal sessions under per-session holder processes so shells /// survive a quick `dosh-server` restart and clients can reconnect. #[serde(default = "default_true")] pub persist_sessions: bool, } impl Default for ServerConfig { fn default() -> Self { Self { port: 50000, bind: "0.0.0.0".to_string(), scrollback: 5000, auth_ttl_secs: 30, attach_ticket_ttl_secs: 3600, allow_attach_tickets: true, client_timeout_secs: 2_592_000, retransmit_window: 256, output_frame_interval_ms: default_output_frame_interval_ms(), default_input_mode: "read-write".to_string(), prewarm_sessions: vec!["default".to_string()], create_on_attach: true, shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()), sessions_dir: "~/.local/share/dosh/sessions".to_string(), secret_path: "~/.config/dosh/secret".to_string(), native_auth: true, host_key: default_host_key(), authorized_keys: default_authorized_keys(), native_auth_rate_limit_per_minute: default_native_auth_rate_limit_per_minute(), rekey_after_packets: default_rekey_after_packets(), rekey_after_secs: default_rekey_after_secs(), allow_tcp_forwarding: true, allow_remote_forwarding: false, allow_remote_non_loopback_bind: false, allow_agent_forwarding: false, allow_file_transfer: true, allow_exec_command: true, accept_env: default_accept_env(), persist_sessions: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientConfig { pub update_repo: Option, pub update_port: Option, pub server: String, pub dosh_host: Option, pub ssh_auth_command: Option, pub ssh_port: Option, pub dosh_port: u16, pub default_session: String, pub reconnect_timeout_secs: u64, pub view_only: bool, #[serde(default = "default_true")] pub predict: bool, /// Prediction display policy: "off", "experimental" (adaptive, the default), /// or "always". Controls when speculative local echo is shown. #[serde(default = "default_predict_mode")] pub predict_mode: String, pub cache_attach_tickets: bool, pub credential_cache: String, #[serde(default = "default_auth_preference")] pub auth_preference: String, #[serde(default)] pub trust_on_first_use: bool, #[serde(default = "default_native_auth_timeout_ms")] pub native_auth_timeout_ms: u64, #[serde(default = "default_known_hosts")] pub known_hosts: String, #[serde(default = "default_identity_files")] pub identity_files: Vec, #[serde(default = "default_true")] pub use_ssh_agent: bool, #[serde(default)] pub forward_agent: bool, /// Show a non-destructive, mosh-style status line on the bottom screen row /// when UDP packets stop arriving ("[dosh] last contact Ns ago …"). Drawn /// with save/restore cursor so it never moves the app cursor or corrupts a /// full-screen TUI, and cleared the instant packets resume. Default on. #[serde(default = "default_true")] pub disconnect_status: bool, #[serde(default = "default_escape_key")] pub escape_key: String, #[serde(default = "default_send_env")] pub send_env: Vec, #[serde(default)] pub set_env: HashMap, /// Optional client-side command extensions. These are just shell command /// templates expanded into the remote session's startup input; Dosh does not /// depend on the tools they name. #[serde(default)] pub extensions: HashMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HostConfig { pub ssh: Option, pub user: Option, pub dosh_host: Option, pub port: Option, pub ssh_port: Option, pub default_command: Option, pub predict: Option, #[serde(default)] pub send_env: Option>, #[serde(default)] pub set_env: HashMap, /// Per-host extension overrides. A host can replace a global extension or set /// `disabled = true` to opt out of it. #[serde(default)] pub extensions: HashMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct CommandExtension { /// Remote shell command template. `{args}` expands to shell-quoted extra /// words after the extension name. When absent, extra args are appended. #[serde(default)] pub command: Option, #[serde(default)] pub description: Option, #[serde(default)] pub disabled: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct HostsConfig { #[serde(flatten)] pub hosts: HashMap, } impl Default for ClientConfig { fn default() -> Self { Self { update_repo: None, update_port: None, server: "user@example.com".to_string(), dosh_host: None, ssh_auth_command: Some("~/.local/bin/dosh-auth".to_string()), ssh_port: None, dosh_port: 50000, default_session: "new".to_string(), reconnect_timeout_secs: 5, view_only: false, predict: true, predict_mode: default_predict_mode(), cache_attach_tickets: true, credential_cache: "~/.local/share/dosh/credentials".to_string(), auth_preference: default_auth_preference(), trust_on_first_use: false, native_auth_timeout_ms: default_native_auth_timeout_ms(), known_hosts: default_known_hosts(), identity_files: default_identity_files(), use_ssh_agent: true, forward_agent: false, disconnect_status: true, escape_key: default_escape_key(), send_env: default_send_env(), set_env: HashMap::new(), extensions: HashMap::new(), } } } fn default_true() -> bool { true } fn default_host_key() -> String { "~/.config/dosh/host_key".to_string() } fn default_authorized_keys() -> Vec { vec![ "~/.ssh/authorized_keys".to_string(), "~/.config/dosh/authorized_keys".to_string(), ] } fn default_native_auth_rate_limit_per_minute() -> u32 { 30 } fn default_output_frame_interval_ms() -> u64 { 16 } fn default_rekey_after_packets() -> u64 { // Rotate well before any AEAD nonce-reuse concern; ChaCha20-Poly1305 with a // per-direction monotonic seq is safe far beyond this, but a bounded epoch // keeps forward-secrecy windows small. 100_000 } fn default_rekey_after_secs() -> u64 { // One hour per epoch by default. 3600 } fn default_auth_preference() -> String { "native,ssh".to_string() } fn default_native_auth_timeout_ms() -> u64 { 700 } fn default_predict_mode() -> String { "experimental".to_string() } fn default_known_hosts() -> String { "~/.config/dosh/known_hosts".to_string() } fn default_escape_key() -> String { "^]".to_string() } fn default_identity_files() -> Vec { vec!["~/.ssh/id_ed25519".to_string()] } fn default_send_env() -> Vec { vec![ "LANG".to_string(), "LC_*".to_string(), "TERM".to_string(), "COLORTERM".to_string(), ] } fn default_accept_env() -> Vec { default_send_env() } pub fn load_server_config(path: Option) -> Result { let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/server.toml")); if !path.exists() { return Ok(ServerConfig::default()); } let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; toml::from_str(&raw).with_context(|| format!("parse {}", path.display())) } pub fn load_client_config(path: Option) -> Result { let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/client.toml")); if !path.exists() { return Ok(ClientConfig::default()); } let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; toml::from_str(&raw).with_context(|| format!("parse {}", path.display())) } pub fn load_hosts_config(path: Option) -> Result { let path = path.unwrap_or_else(|| expand_tilde("~/.config/dosh/hosts.toml")); if !path.exists() { return Ok(HostsConfig::default()); } let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; toml::from_str(&raw).with_context(|| format!("parse {}", path.display())) } pub fn expand_tilde(path: &str) -> PathBuf { if let Some(rest) = path.strip_prefix("~/") && let Some(home) = dirs::home_dir() { return home.join(rest); } PathBuf::from(path) }