Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
//! Server profiles and trusted host keys stored in the vault2
//! (port of `profiles/profileService.js`; identical vault JSON layout).4
use crate::core::error::{CoreError, CoreResult};5
use crate::core::vault::VaultStore;6
use once_cell::sync::Lazy;7
use regex::Regex;8
use serde_json::{json, Map, Value};9
use std::net::IpAddr;10
use std::sync::Arc;12
static 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
});15
static FINGERPRINT: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-fA-F0-9]{64}$").unwrap());17
pub 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
}23
}25
pub 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())31
}33
/// JS `Number(value || 22)` semantics for numbers and numeric strings.34
pub 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)49
}51
fn has_forbidden(value: &str) -> bool {52
value.contains('\0') || value.contains('\r') || value.contains('\n')53
}55
fn 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
}62
}64
fn now_iso() -> String {65
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)66
}68
pub 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 <= 10086
}).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
}))115
}117
pub fn host_key_id(host: &str, port: Option<&Value>) -> CoreResult<String> {118
Ok(format!("{}:{}", normalize_host(host)?, normalize_port(port)?))119
}121
fn 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())126
}128
fn 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")136
}138
fn 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)140
}142
fn sort_key(profile: &Value) -> String {143
profile144
.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()150
}152
pub struct ProfileService {153
pub vault: Arc<VaultStore>,154
}156
impl 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
profile172
})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
}280
}282
#[cfg(test)]283
mod 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 = service297
.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
}322
}SHA-256
721ab4d97e3ed79dbb2f44cadb6eaa71a7ef8badcf354d03cd95f4bc332c1083CH-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 Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df