Skip to main content

ssh_cli/vps/
modelo.rs

1//! Modelo de dados `VpsRegistro` (schema v2).
2//!
3//! Senhas usam `SecretString` para zeroize automático via `Drop`. O TOML
4//! gravado em disco: texto claro (0o600) ou cifrado (`sshcli-enc:v1:`) se houver chave mestra.
5//! `Debug` é customizado para NUNCA expor valores sensíveis.
6//!
7//! Schema v2: auth por senha **ou** chave, dualidade max_command/max_output,
8//! `disable_sudo` e migração automática de `max_chars` legado.
9
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Deserialize, Serialize};
12
13/// Versão atual do schema do arquivo `config.toml`.
14pub const SCHEMA_VERSION_ATUAL: u32 = 2;
15
16/// Timeout padrão em milissegundos (default 60s).
17pub const TIMEOUT_PADRAO_MS: u64 = 60_000;
18
19/// Limite padrão de caracteres no **comando** (automação one-shot maxChars).
20pub const MAX_COMMAND_CHARS_PADRAO: usize = 1_000;
21
22/// Limite padrão de caracteres em **output** capturado.
23pub const MAX_OUTPUT_CHARS_PADRAO: usize = 100_000;
24
25/// Registro de uma VPS no arquivo de configuração.
26#[derive(Clone, Serialize, Deserialize)]
27pub struct VpsRegistro {
28    /// Nome lógico único da VPS.
29    pub nome: String,
30    /// Hostname ou IP do servidor.
31    pub host: String,
32    /// Porta SSH.
33    pub porta: u16,
34    /// Usuário SSH.
35    pub usuario: String,
36    /// Senha SSH (vazia se auth só por chave).
37    #[serde(default, with = "secret_string_serde")]
38    pub senha: SecretString,
39    /// Caminho absoluto ou expandível para chave privada OpenSSH.
40    #[serde(default)]
41    pub key_path: Option<String>,
42    /// Passphrase da chave privada (opcional).
43    #[serde(default, with = "opcao_secret_string_serde")]
44    pub key_passphrase: Option<SecretString>,
45    /// Timeout em milissegundos.
46    pub timeout_ms: u64,
47    /// Limite de caracteres do comando (entrada). `0` = ilimitado em runtime.
48    #[serde(default = "default_max_command_chars")]
49    pub max_command_chars: usize,
50    /// Limite de caracteres de stdout/stderr. Aceita alias legado `max_chars`.
51    #[serde(default = "default_max_output_chars", alias = "max_chars")]
52    pub max_output_chars: usize,
53    /// Senha para `sudo` (opcional).
54    #[serde(default, with = "opcao_secret_string_serde")]
55    pub senha_sudo: Option<SecretString>,
56    /// Senha para `su -` (opcional).
57    #[serde(default, with = "opcao_secret_string_serde")]
58    pub senha_su: Option<SecretString>,
59    /// Se true, `sudo-exec` e `su-exec` são recusados para este host.
60    #[serde(default)]
61    pub disable_sudo: bool,
62    /// Versão do schema deste registro.
63    pub schema_version: u32,
64    /// Timestamp RFC 3339 de inclusão.
65    pub adicionado_em: String,
66}
67
68fn default_max_command_chars() -> usize {
69    MAX_COMMAND_CHARS_PADRAO
70}
71
72fn default_max_output_chars() -> usize {
73    MAX_OUTPUT_CHARS_PADRAO
74}
75
76impl std::fmt::Debug for VpsRegistro {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("VpsRegistro")
79            .field("nome", &self.nome)
80            .field("host", &self.host)
81            .field("porta", &self.porta)
82            .field("usuario", &self.usuario)
83            .field("senha", &"<redacted>")
84            .field("key_path", &self.key_path)
85            .field(
86                "key_passphrase",
87                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
88            )
89            .field("timeout_ms", &self.timeout_ms)
90            .field("max_command_chars", &self.max_command_chars)
91            .field("max_output_chars", &self.max_output_chars)
92            .field(
93                "senha_sudo",
94                &self.senha_sudo.as_ref().map(|_| "<redacted>"),
95            )
96            .field("senha_su", &self.senha_su.as_ref().map(|_| "<redacted>"))
97            .field("disable_sudo", &self.disable_sudo)
98            .field("schema_version", &self.schema_version)
99            .field("adicionado_em", &self.adicionado_em)
100            .finish()
101    }
102}
103
104impl VpsRegistro {
105    /// Cria um novo registro aplicando defaults.
106    #[must_use]
107    #[allow(clippy::too_many_arguments)]
108    pub fn novo(
109        nome: String,
110        host: String,
111        porta: u16,
112        usuario: String,
113        senha: SecretString,
114        key_path: Option<String>,
115        key_passphrase: Option<SecretString>,
116        timeout_ms: Option<u64>,
117        max_command_chars: Option<usize>,
118        max_output_chars: Option<usize>,
119        senha_sudo: Option<SecretString>,
120        senha_su: Option<SecretString>,
121        disable_sudo: bool,
122    ) -> Self {
123        Self {
124            nome,
125            host,
126            porta,
127            usuario,
128            senha,
129            key_path,
130            key_passphrase,
131            timeout_ms: timeout_ms.unwrap_or(TIMEOUT_PADRAO_MS),
132            max_command_chars: max_command_chars.unwrap_or(MAX_COMMAND_CHARS_PADRAO),
133            max_output_chars: max_output_chars.unwrap_or(MAX_OUTPUT_CHARS_PADRAO),
134            senha_sudo,
135            senha_su,
136            disable_sudo,
137            schema_version: SCHEMA_VERSION_ATUAL,
138            adicionado_em: chrono::Utc::now().to_rfc3339(),
139        }
140    }
141
142    /// Retorna true se há senha não vazia.
143    #[must_use]
144    pub fn tem_senha(&self) -> bool {
145        !self.senha.expose_secret().is_empty()
146    }
147
148    /// Retorna true se há caminho de chave privada.
149    #[must_use]
150    pub fn tem_chave(&self) -> bool {
151        self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty())
152    }
153
154    /// Valida que existe pelo menos um método de autenticação.
155    pub fn validar_credenciais(&self) -> Result<(), String> {
156        if !self.tem_senha() && !self.tem_chave() {
157            return Err(
158                "é obrigatório fornecer --password ou --key (auth password ou chave privada)"
159                    .to_string(),
160            );
161        }
162        Ok(())
163    }
164
165    /// Validação completa do registro na fronteira de escrita (add/edit/import).
166    ///
167    /// Garante porta ∈ 1..=65535, host/usuário não vazios e credenciais presentes.
168    /// Não verifica existência de `key_path` no filesystem (isso fica no dispatcher).
169    pub fn validar(&self) -> Result<(), String> {
170        if self.porta == 0 {
171            return Err("porta SSH inválida: 0 (use 1..=65535)".to_string());
172        }
173        if self.host.trim().is_empty() {
174            return Err("host não pode ser vazio".to_string());
175        }
176        if self.usuario.trim().is_empty() {
177            return Err("usuário SSH não pode ser vazio".to_string());
178        }
179        self.validar_credenciais()
180    }
181
182    /// Normaliza schema após deserialização (migração v1 → v2).
183    pub fn normalizar_schema(&mut self) {
184        if self.schema_version < SCHEMA_VERSION_ATUAL {
185            self.schema_version = SCHEMA_VERSION_ATUAL;
186        }
187        if self.max_command_chars == 0 && self.max_output_chars == 0 {
188            // nada: 0 significa ilimitado na validação de runtime
189        }
190    }
191}
192
193/// Interpreta string de limite (`"none"`, `"0"` ou número).
194///
195/// `0`/`none` → `0` (ilimitado no runtime).
196#[must_use]
197pub fn parse_limite_chars(s: &str) -> usize {
198    let t = s.trim();
199    if t.eq_ignore_ascii_case("none") || t == "0" {
200        0
201    } else {
202        t.parse().unwrap_or(MAX_OUTPUT_CHARS_PADRAO)
203    }
204}
205
206/// Converte limite de config em valor efetivo para truncagem/validação.
207///
208/// `0` = sem limite (`usize::MAX` para comparação).
209#[must_use]
210pub fn limite_efetivo(configurado: usize) -> usize {
211    if configurado == 0 {
212        usize::MAX
213    } else {
214        configurado
215    }
216}
217
218mod secret_string_serde {
219    use super::{ExposeSecret, SecretString};
220    use serde::{Deserialize, Deserializer, Serializer};
221
222    pub fn serialize<S: Serializer>(valor: &SecretString, s: S) -> Result<S::Ok, S::Error> {
223        let plain = valor.expose_secret();
224        let out = crate::secrets::serializar_segredo(plain).map_err(serde::ser::Error::custom)?;
225        s.serialize_str(&out)
226    }
227
228    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
229        let s = String::deserialize(d)?;
230        let plain = crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
231        Ok(SecretString::from(plain))
232    }
233}
234
235mod opcao_secret_string_serde {
236    use super::{ExposeSecret, SecretString};
237    use serde::{Deserialize, Deserializer, Serializer};
238
239    pub fn serialize<S: Serializer>(valor: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
240        match valor {
241            Some(v) => {
242                let out = crate::secrets::serializar_segredo(v.expose_secret())
243                    .map_err(serde::ser::Error::custom)?;
244                s.serialize_some(&out)
245            }
246            None => s.serialize_none(),
247        }
248    }
249
250    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
251        let opt = Option::<String>::deserialize(d)?;
252        match opt {
253            None => Ok(None),
254            Some(s) => {
255                let plain =
256                    crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
257                Ok(Some(SecretString::from(plain)))
258            }
259        }
260    }
261}
262
263#[cfg(test)]
264mod testes {
265    use super::*;
266
267    #[test]
268    fn novo_registro_aplica_defaults() {
269        let r = VpsRegistro::novo(
270            "teste".into(),
271            "1.2.3.4".into(),
272            22,
273            "root".into(),
274            SecretString::from("senha".to_string()),
275            None,
276            None,
277            None,
278            None,
279            None,
280            None,
281            None,
282            false,
283        );
284        assert_eq!(r.timeout_ms, TIMEOUT_PADRAO_MS);
285        assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
286        assert_eq!(r.max_output_chars, MAX_OUTPUT_CHARS_PADRAO);
287        assert_eq!(r.schema_version, SCHEMA_VERSION_ATUAL);
288        assert!(!r.adicionado_em.is_empty());
289    }
290
291    #[test]
292    fn debug_nao_exibe_senha() {
293        let r = VpsRegistro::novo(
294            "t".into(),
295            "h".into(),
296            22,
297            "u".into(),
298            SecretString::from("senha-super-secreta".to_string()),
299            None,
300            None,
301            None,
302            None,
303            None,
304            None,
305            None,
306            false,
307        );
308        let dbg = format!("{r:?}");
309        assert!(!dbg.contains("senha-super-secreta"));
310        assert!(dbg.contains("redacted"));
311    }
312
313    #[test]
314    #[serial_test::serial]
315    fn round_trip_toml_preserva_dados() {
316        // Isola cifragem at-rest de outros testes (master-key global).
317        let tmp = tempfile::TempDir::new().unwrap();
318        crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
319        // SAFETY: teste serial; restaura opt-out de plaintext.
320        // SAFETY: env mutável só em teste serial.
321        unsafe {
322            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
323        }
324        let r = VpsRegistro::novo(
325            "producao".into(),
326            "srv.exemplo.com".into(),
327            2222,
328            "admin".into(),
329            SecretString::from("senha-do-admin-longa".to_string()),
330            Some("/home/u/.ssh/id_ed25519".into()),
331            None,
332            Some(5000),
333            Some(500),
334            Some(50_000),
335            Some(SecretString::from("sudopass".to_string())),
336            None,
337            false,
338        );
339        let toml_str = toml::to_string(&r).expect("serializar");
340        let r2: VpsRegistro = toml::from_str(&toml_str).expect("deserializar");
341        assert_eq!(r2.nome, "producao");
342        assert_eq!(r2.porta, 2222);
343        assert_eq!(r2.senha.expose_secret(), "senha-do-admin-longa");
344        assert_eq!(r2.key_path.as_deref(), Some("/home/u/.ssh/id_ed25519"));
345        assert_eq!(r2.max_command_chars, 500);
346        assert_eq!(r2.max_output_chars, 50_000);
347        assert_eq!(
348            r2.senha_sudo
349                .as_ref()
350                .map(|s| s.expose_secret().to_string()),
351            Some("sudopass".to_string())
352        );
353        assert!(r2.senha_su.is_none());
354        unsafe {
355            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
356        }
357        crate::secrets::definir_diretorio_config(None);
358    }
359
360    #[test]
361    fn migra_max_chars_legado() {
362        let legado = r#"
363nome = "x"
364host = "h"
365porta = 22
366usuario = "u"
367senha = "s"
368timeout_ms = 30000
369max_chars = 4242
370schema_version = 1
371adicionado_em = "2020-01-01T00:00:00Z"
372"#;
373        let r: VpsRegistro = toml::from_str(legado).expect("deserializar legado");
374        assert_eq!(r.max_output_chars, 4242);
375        assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
376    }
377
378    #[test]
379    fn validar_credenciais_exige_password_ou_key() {
380        let mut r = VpsRegistro::novo(
381            "t".into(),
382            "h".into(),
383            22,
384            "u".into(),
385            SecretString::from(String::new()),
386            None,
387            None,
388            None,
389            None,
390            None,
391            None,
392            None,
393            false,
394        );
395        assert!(r.validar_credenciais().is_err());
396        r.key_path = Some("/tmp/k".into());
397        assert!(r.validar_credenciais().is_ok());
398    }
399
400    #[test]
401    fn parse_limite_none_e_zero() {
402        assert_eq!(parse_limite_chars("none"), 0);
403        assert_eq!(parse_limite_chars("0"), 0);
404        assert_eq!(parse_limite_chars("1000"), 1000);
405        assert_eq!(limite_efetivo(0), usize::MAX);
406        assert_eq!(limite_efetivo(10), 10);
407    }
408}