CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
profiles.rs Hinzugefügt: Datum nicht erfasst 13,6 KB · 322 Zeilen
Code Datei herunterladen
1//! Server profiles and trusted host keys stored in the vault
2//! (port of `profiles/profileService.js`; identical vault JSON layout).
4use crate::core::error::{CoreError, CoreResult};
5use crate::core::vault::VaultStore;
6use once_cell::sync::Lazy;
7use regex::Regex;
8use serde_json::{json, Map, Value};
9use std::net::IpAddr;
10use std::sync::Arc;
12static HOSTNAME: Lazy<Regex> = Lazy::new(|| {
13 Regex::new(r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*$").unwrap()
14});
15static FINGERPRINT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-fA-F0-9]{64}$").unwrap());
17pub fn is_ip(value: &str) -> Option<u8> {
18 match value.parse::<IpAddr>() {
19 Ok(IpAddr::V4(_)) => Some(4),
20 Ok(IpAddr::V6(_)) => Some(6),
21 Err(_) => None,
22 }
25pub fn normalize_host(value: &str) -> CoreResult<String> {
26 let host = value.trim();
27 if host.is_empty() || (is_ip(host).is_none() && (host.len() > 253 || !HOSTNAME.is_match(host))) {
28 return Err(CoreError::plain("Invalid server host name or IP address."));
29 }
30 Ok(host.to_lowercase())
33/// JS `Number(value || 22)` semantics for numbers and numeric strings.
34pub fn normalize_port(value: Option<&Value>) -> CoreResult<u16> {
35 let number = match value {
36 None | Some(Value::Null) | Some(Value::Bool(false)) => 22.0,
37 Some(Value::Number(n)) => {
38 let n = n.as_f64().unwrap_or(f64::NAN);
39 if n == 0.0 { 22.0 } else { n }
40 }
41 Some(Value::String(s)) if s.is_empty() => 22.0,
42 Some(Value::String(s)) => s.trim().parse::<f64>().unwrap_or(f64::NAN),
43 _ => f64::NAN,
44 };
45 if number.fract() != 0.0 || !(1.0..=65535.0).contains(&number) {
46 return Err(CoreError::plain("SSH port must be between 1 and 65535."));
47 }
48 Ok(number as u16)
51fn has_forbidden(value: &str) -> bool {
52 value.contains('\0') || value.contains('\r') || value.contains('\n')
55fn text(input: &Value, key: &str) -> String {
56 match input.get(key) {
57 Some(Value::String(s)) => s.clone(),
58 Some(Value::Number(n)) => n.to_string(),
59 Some(Value::Bool(b)) => b.to_string(),
60 _ => String::new(),
61 }
64fn now_iso() -> String {
65 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
68pub fn normalize_profile(input: &Value, existing: Option<&Value>) -> CoreResult<Value> {
69 normalize_host(&text(input, "host"))?;
70 let host = text(input, "host").trim().to_string();
71 let username = text(input, "username").trim().to_string();
72 if username.is_empty() || username.encode_utf16().count() > 128 || has_forbidden(&username) {
73 return Err(CoreError::plain("Invalid SSH username."));
74 }
75 let auth_method = if input.get("authMethod").and_then(Value::as_str) == Some("privateKey") { "privateKey" } else { "password" };
76 let private_key_path = text(input, "privateKeyPath").trim().to_string();
77 if auth_method == "privateKey" && (private_key_path.is_empty() || has_forbidden(&private_key_path)) {
78 return Err(CoreError::plain("Select a private key file."));
79 }
80 let now = now_iso();
81 let label_source = { let l = text(input, "label"); if l.is_empty() { host.clone() } else { l } };
82 let mut units = 0;
83 let mut label: String = label_source.trim().chars().take_while(|c| {
84 units += c.len_utf16();
85 units <= 100
86 }).collect();
87 if label.is_empty() {
88 label = host.clone();
89 }
90 let mut tags: Vec<Value> = Vec::new();
91 if let Some(Value::Array(items)) = input.get("tags") {
92 for item in items {
93 let tag = match item { Value::String(s) => s.trim().to_string(), Value::Null => "null".into(), other => other.to_string() };
94 if !tag.is_empty() && !tags.iter().any(|t| t.as_str() == Some(tag.as_str())) {
95 tags.push(Value::String(tag));
96 }
97 }
98 tags.truncate(20);
99 }
100 let existing_field = |key: &str| existing.and_then(|e| e.get(key)).cloned();
101 Ok(json!({
102 "id": existing_field("id").unwrap_or_else(|| Value::String(uuid::Uuid::new_v4().to_string())),
103 "label": label,
104 "host": host,
105 "port": normalize_port(input.get("port"))?,
106 "username": username,
107 "authMethod": auth_method,
108 "privateKeyPath": if auth_method == "privateKey" { private_key_path } else { String::new() },
109 "tags": tags,
110 "jumpProfileId": input.get("jumpProfileId").or_else(|| existing.and_then(|p| p.get("jumpProfileId"))).and_then(Value::as_str).unwrap_or("").trim(),
111 "createdAt": existing_field("createdAt").unwrap_or_else(|| Value::String(now.clone())),
112 "updatedAt": now,
113 "lastUsedAt": existing_field("lastUsedAt").unwrap_or(Value::Null)
114 }))
117pub fn host_key_id(host: &str, port: Option<&Value>) -> CoreResult<String> {
118 Ok(format!("{}:{}", normalize_host(host)?, normalize_port(port)?))
121fn validate_stored_password(value: &str) -> CoreResult<String> {
122 if value.is_empty() || value.encode_utf16().count() > 1024 || value.contains('\0') {
123 return Err(CoreError::plain("Stored SSH password is invalid."));
124 }
125 Ok(value.to_string())
128fn password_store(data: &mut Value) -> &mut Map<String, Value> {
129 if !data.get("secrets").map(Value::is_object).unwrap_or(false) {
130 data["secrets"] = json!({});
131 }
132 if !data["secrets"].get("profilePasswords").map(Value::is_object).unwrap_or(false) {
133 data["secrets"]["profilePasswords"] = json!({});
134 }
135 data["secrets"]["profilePasswords"].as_object_mut().expect("object")
138fn has_password(data: &Value, id: &str) -> bool {
139 data.pointer("/secrets/profilePasswords").and_then(|p| p.get(id)).map(Value::is_string).unwrap_or(false)
142fn sort_key(profile: &Value) -> String {
143 profile
144 .get("lastUsedAt")
145 .and_then(Value::as_str)
146 .filter(|s| !s.is_empty())
147 .or_else(|| profile.get("updatedAt").and_then(Value::as_str))
148 .unwrap_or("")
149 .to_string()
152pub struct ProfileService {
153 pub vault: Arc<VaultStore>,
156impl ProfileService {
157 pub fn new(vault: Arc<VaultStore>) -> Self {
158 Self { vault }
159 }
161 pub fn list(&self) -> CoreResult<Vec<Value>> {
162 let data = self.vault.get_data()?;
163 let mut profiles: Vec<Value> = data["profiles"]
164 .as_array()
165 .cloned()
166 .unwrap_or_default()
167 .into_iter()
168 .map(|mut profile| {
169 let id = profile.get("id").and_then(Value::as_str).unwrap_or("").to_string();
170 profile["hasStoredPassword"] = Value::Bool(has_password(&data, &id));
171 profile
172 })
173 .collect();
174 profiles.sort_by(|a, b| sort_key(b).cmp(&sort_key(a)));
175 Ok(profiles)
176 }
178 pub fn get(&self, id: &str) -> CoreResult<Value> {
179 let data = self.vault.get_data()?;
180 let mut profile = data["profiles"]
181 .as_array()
182 .and_then(|items| items.iter().find(|p| p.get("id").and_then(Value::as_str) == Some(id)).cloned())
183 .ok_or_else(|| CoreError::plain("Server profile was not found."))?;
184 profile["hasStoredPassword"] = Value::Bool(has_password(&data, id));
185 Ok(profile)
186 }
188 pub fn save(&self, input: &Value) -> CoreResult<Value> {
189 let id = self.vault.update(|data| {
190 let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;
191 let input_id = input.get("id").and_then(Value::as_str);
192 let index = input_id.and_then(|id| profiles.iter().position(|p| p.get("id").and_then(Value::as_str) == Some(id)));
193 let saved = normalize_profile(input, index.map(|i| &profiles[i]))?;
194 let saved_id = saved["id"].as_str().unwrap_or("").to_string();
195 let mut seen = std::collections::HashSet::from([saved_id.clone()]);
196 let mut next = saved["jumpProfileId"].as_str().unwrap_or("");
197 while !next.is_empty() {
198 if !seen.insert(next.to_string()) {
199 return Err(CoreError::new("JUMP_LOOP", "The jump route contains a cycle."));
200 }
201 let hop = profiles.iter().find(|p| p["id"].as_str() == Some(next))
202 .ok_or_else(|| CoreError::new("JUMP_PROFILE_NOT_FOUND", "Jump profile was not found."))?;
203 next = hop["jumpProfileId"].as_str().unwrap_or("");
204 }
205 let auth_password = saved["authMethod"] == "password";
206 match index {
207 Some(i) => profiles[i] = saved,
208 None => profiles.push(saved),
209 }
210 let store_password = input.get("storePassword").and_then(Value::as_bool);
211 let passwords = password_store(data);
212 if !auth_password || store_password == Some(false) {
213 passwords.remove(&saved_id);
214 } else if store_password == Some(true) {
215 let password = text(input, "password");
216 if !password.is_empty() {
217 passwords.insert(saved_id.clone(), Value::String(validate_stored_password(&password)?));
218 } else if !passwords.get(&saved_id).map(Value::is_string).unwrap_or(false) {
219 return Err(CoreError::plain("Enter the SSH password to store it in the vault."));
220 }
221 }
222 Ok(saved_id)
223 })?;
224 self.get(&id)
225 }
227 pub fn delete(&self, id: &str) -> CoreResult<Value> {
228 let removed = self.vault.update(|data| {
229 let profiles = data["profiles"].as_array_mut().ok_or_else(|| CoreError::plain("Vault profiles are invalid."))?;
230 let before = profiles.len();
231 profiles.retain(|p| p.get("id").and_then(Value::as_str) != Some(id));
232 let removed = before != profiles.len();
233 for profile in profiles.iter_mut() {
234 if profile["jumpProfileId"].as_str() == Some(id) {
235 profile["jumpProfileId"] = json!("");
236 }
237 }
238 password_store(data).remove(id);
239 Ok(removed)
240 })?;
241 Ok(json!({ "removed": removed }))
242 }
244 pub fn mark_used(&self, id: &str) -> CoreResult<()> {
245 self.vault.update(|data| {
246 if let Some(profile) = data["profiles"].as_array_mut().and_then(|p| p.iter_mut().find(|p| p.get("id").and_then(Value::as_str) == Some(id))) {
247 profile["lastUsedAt"] = Value::String(now_iso());
248 }
249 Ok(())
250 })
251 }
253 pub fn get_host_key(&self, host: &str, port: u16) -> CoreResult<Option<String>> {
254 let id = host_key_id(host, Some(&json!(port)))?;
255 Ok(self.vault.get_data()?["hostKeys"].get(&id).and_then(Value::as_str).map(str::to_string))
256 }
258 pub fn get_stored_password(&self, profile_id: &str) -> CoreResult<Option<String>> {
259 let data = self.vault.get_data()?;
260 let exists = data["profiles"].as_array().map(|p| p.iter().any(|p| p.get("id").and_then(Value::as_str) == Some(profile_id))).unwrap_or(false);
261 if !exists {
262 return Err(CoreError::plain("Server profile was not found."));
263 }
264 Ok(data.pointer("/secrets/profilePasswords").and_then(|p| p.get(profile_id)).and_then(Value::as_str).map(str::to_string))
265 }
267 pub fn trust_host_key(&self, host: &str, port: u16, fingerprint: &str) -> CoreResult<Value> {
268 let normalized = fingerprint.trim();
269 if !FINGERPRINT.is_match(normalized) {
270 return Err(CoreError::plain("Invalid SHA-256 host fingerprint."));
271 }
272 let normalized = normalized.to_lowercase();
273 let id = host_key_id(host, Some(&json!(port)))?;
274 self.vault.update(|data| {
275 data["hostKeys"][&id] = Value::String(normalized.clone());
276 Ok(())
277 })?;
278 Ok(json!({ "host": normalize_host(host)?, "port": port, "fingerprint": normalized }))
279 }
282#[cfg(test)]
283mod tests {
284 use super::*;
286 async fn service() -> (tempfile::TempDir, ProfileService) {
287 let dir = tempfile::tempdir().unwrap();
288 let vault = Arc::new(VaultStore::new(dir.path()));
289 vault.create("heslo123").await.unwrap();
290 (dir, ProfileService::new(vault))
291 }
293 #[tokio::test]
294 async fn save_list_password_and_host_keys() {
295 let (_dir, service) = service().await;
296 let saved = service
297 .save(&json!({"label": "Web", "host": "Example.COM", "port": "2222", "username": "root", "authMethod": "password", "storePassword": true, "password": "pw"}))
298 .unwrap();
299 assert_eq!(saved["port"], 2222);
300 assert_eq!(saved["host"], "Example.COM");
301 assert_eq!(saved["hasStoredPassword"], true);
302 let id = saved["id"].as_str().unwrap();
303 assert_eq!(service.get_stored_password(id).unwrap().as_deref(), Some("pw"));
304 service.trust_host_key("example.com", 2222, &"AB".repeat(32)).unwrap();
305 assert_eq!(service.get_host_key("EXAMPLE.com", 2222).unwrap().unwrap(), "ab".repeat(32));
306 let updated = service.save(&json!({"id": id, "host": "example.com", "username": "root", "storePassword": false})).unwrap();
307 assert_eq!(updated["hasStoredPassword"], false);
308 assert_eq!(updated["createdAt"], saved["createdAt"]);
309 assert_eq!(service.delete(id).unwrap()["removed"], true);
310 assert!(service.list().unwrap().is_empty());
311 }
313 #[test]
314 fn validation() {
315 assert!(normalize_host("bad host").is_err());
316 assert!(normalize_host("::1").is_ok());
317 assert!(normalize_port(Some(&json!(0))).is_ok());
318 assert!(normalize_port(Some(&json!(70000))).is_err());
319 assert!(normalize_profile(&json!({"host": "a.b", "username": "x\ny"}), None).is_err());
320 assert!(normalize_profile(&json!({"host": "a.b", "username": "x", "authMethod": "privateKey"}), None).is_err());
321 }
SHA-256721ab4d97e3ed79dbb2f44cadb6eaa71a7ef8badcf354d03cd95f4bc332c1083

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien