Julkaistu lähde
Lataa lähde ZIP
CH-J Tools
Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.
CH-J / Server Manager
1704 tiedostot
0.1.2-beta
Lisätty: · Rakennettu:
1
//! Settings of the separate `ch-j-standby-helper` (see `src/standby/`). The application only writes the helper's2
//! switches to `<data>/standby-helper.json` and runs `ch-j-standby-helper apply|status`; the helper owns every3
//! system entry (menu items, login item, background process). The file holds no secrets.4
use crate::core::error::{CoreError, CoreResult};5
use serde_json::{json, Value};6
use std::path::{Path, PathBuf};7
use std::time::Duration;9
pub const FILE: &str = "standby-helper.json";11
/// Where the macOS release bundle keeps the helper as its own app, so login items show "CH-J-Standby-Helper"12
/// (a bare binary is listed under the certificate's developer name). Relative to `Contents/MacOS`.13
pub const MAC_HELPER_IN_BUNDLE: &str = "../Library/LoginItems/CH-J-Standby-Helper.app/Contents/MacOS/ch-j-standby-helper";15
pub fn helper_binary() -> PathBuf {16
let name = if cfg!(windows) { "ch-j-standby-helper.exe" } else { "ch-j-standby-helper" };17
let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)) else { return PathBuf::from(name) };18
let nested = dir.join(MAC_HELPER_IN_BUNDLE);19
if cfg!(target_os = "macos") && nested.is_file() {20
return std::fs::canonicalize(&nested).unwrap_or(nested);21
}22
dir.join(name)23
}25
/// Only the known switches survive; anything else in the input is dropped.26
pub fn normalize(input: &Value) -> Value {27
let flag = |value: Option<&Value>, default: bool| value.and_then(Value::as_bool).unwrap_or(default);28
let monitor = input.get("monitor").cloned().unwrap_or(Value::Null);29
let interval = monitor["intervalSecs"].as_u64().filter(|s| MONITOR_INTERVALS.contains(s)).unwrap_or(60);30
let check = monitor["check"].as_str().filter(|c| ["ssh", "https", "custom"].contains(c)).unwrap_or("ssh");31
let custom_port = monitor["customPort"].as_u64().filter(|p| (1..=65535).contains(p)).unwrap_or(8443);32
json!({33
"enabled": flag(input.get("enabled"), false),34
"startAtLogin": flag(input.get("startAtLogin"), true),35
"menu": {36
"verify": flag(input.get("menu").and_then(|m| m.get("verify")), true),37
"sign": flag(input.get("menu").and_then(|m| m.get("sign")), true),38
},39
"monitor": {40
"enabled": flag(monitor.get("enabled"), false),41
"intervalSecs": interval,42
"check": check,43
"customPort": custom_port,44
"showAddresses": flag(monitor.get("showAddresses"), true),45
"servers": normalize_server_choices(&monitor["servers"]),46
}47
})48
}50
/// Check types and their default ports. `ssh` without a port uses the profile's SSH port; `tcp` needs a port.51
/// Only `ssh` is verified beyond "port open" (banner); the helper accepts the same names.52
pub const CHECK_TYPES: [(&str, u16); 8] = [("ssh", 22), ("https", 443), ("http", 80), ("smb", 445), ("ftp", 21), ("dns", 53), ("rdp", 3389), ("tcp", 0)];53
const MAX_CHECKS_PER_SERVER: usize = 8;54
const MAX_SERVER_CHOICES: usize = 500;56
/// Per saved server: `{"<profile id>": {"enabled": bool, "checks": [{"type": "https", "port": 443 | null}]}}`.57
/// Servers without an entry are monitored with the default check; `enabled: false` leaves one out.58
fn normalize_server_choices(input: &Value) -> Value {59
let mut out = serde_json::Map::new();60
for (id, choice) in input.as_object().into_iter().flatten().take(MAX_SERVER_CHOICES) {61
if id.is_empty() || id.len() > 64 {62
continue;63
}64
let checks: Vec<Value> = choice["checks"]65
.as_array()66
.into_iter()67
.flatten()68
.filter_map(|check| {69
let kind = check["type"].as_str().filter(|t| CHECK_TYPES.iter().any(|(name, _)| name == t))?;70
let port = check["port"].as_u64().filter(|p| (1..=65535).contains(p));71
if kind == "tcp" && port.is_none() {72
return None;73
}74
Some(json!({"type": kind, "port": port}))75
})76
.take(MAX_CHECKS_PER_SERVER)77
.collect();78
out.insert(id.clone(), json!({"enabled": choice["enabled"].as_bool().unwrap_or(true), "checks": checks}));79
}80
Value::Object(out)81
}83
/// Background availability monitoring by the helper (`standby-helper/src/monitor.rs`). The helper never reads the84
/// vault, so while monitoring is on the app keeps `standby-servers.json` (0600: name, host, port, check type only –85
/// no user names, passwords or keys). Turning it off deletes the file. This is the owner-approved, opt-in exception86
/// to "availability state lives only in memory while the vault is unlocked".87
pub const SERVERS_FILE: &str = "standby-servers.json";88
pub const MONITOR_INTERVALS: [u64; 3] = [30, 60, 300];89
const MAX_SERVERS: usize = 200;91
/// The servers document for the helper, or `None` when monitoring is off. Profiles behind a jump host are left out:92
/// the helper connects directly and could only report them as offline.93
pub fn servers_document(settings: &Value, profiles: &[Value]) -> Option<Value> {94
let settings = normalize(settings);95
let monitor = &settings["monitor"];96
if settings["enabled"] != true || monitor["enabled"] != true {97
return None;98
}99
let default_check = match monitor["check"].as_str().unwrap_or("ssh") {100
"https" => json!({"type": "https", "port": null}),101
"custom" => json!({"type": "tcp", "port": monitor["customPort"]}),102
_ => json!({"type": "ssh", "port": null}),103
};104
let servers: Vec<Value> = profiles105
.iter()106
.filter(|p| p["jumpProfileId"].as_str().is_none_or(str::is_empty))107
.filter_map(|p| Some((p, crate::core::availability::Target::from_profile(p)?)))108
.flat_map(|(p, target)| {109
let choice = &monitor["servers"][target.id.as_str()];110
let checks: Vec<Value> = if choice["enabled"] == false {111
Vec::new()112
} else {113
choice["checks"].as_array().filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| vec![default_check.clone()])114
};115
let name = p["label"].as_str().filter(|l| !l.is_empty()).unwrap_or(&target.host).chars().take(128).collect::<String>();116
checks.into_iter().filter_map(move |check| {117
let kind = check["type"].as_str()?;118
let default = CHECK_TYPES.iter().find(|(n, _)| *n == kind)?.1;119
let port = check["port"].as_u64().map(|p| p as u16).unwrap_or(if kind == "ssh" { target.port } else { default });120
// One entry per check; the id stays unique and stable per server, type and port.121
Some(json!({"id": format!("{}:{kind}:{port}", target.id), "name": name, "host": target.host, "port": port, "protocol": kind}))122
})123
})124
.filter(|entry| entry["id"].as_str().is_some_and(|id| id.len() <= 64))125
.take(MAX_SERVERS)126
.collect();127
Some(json!({"version": 1, "enabled": true, "intervalSecs": monitor["intervalSecs"], "showAddresses": monitor["showAddresses"], "servers": servers}))128
}130
/// Brings `standby-servers.json` in line with the settings. `profiles` is `None` while the vault is locked: the131
/// last written list then stays (monitoring continues with the app closed); only "off" removes it.132
pub fn sync_servers(data: &Path, profiles: Option<&[Value]>) -> CoreResult<()> {133
sync_servers_at(data, profiles, MONITOR_AVAILABLE)134
}136
fn sync_servers_at(data: &Path, profiles: Option<&[Value]>, monitor_available: bool) -> CoreResult<()> {137
let path = data.join(SERVERS_FILE);138
let settings = load_at(data, monitor_available);139
if settings["enabled"] != true || settings["monitor"]["enabled"] != true {140
return match std::fs::remove_file(&path) {141
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),142
_ => Ok(()),143
};144
}145
let Some(profiles) = profiles else {146
// Locked vault: no profiles to rebuild the list, but the global choices must still reach the helper147
// (hiding addresses in particular must not wait for the next unlock).148
let Some(mut document) = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return Ok(()) };149
let monitor = &settings["monitor"];150
if document["showAddresses"] == monitor["showAddresses"] && document["intervalSecs"] == monitor["intervalSecs"] {151
return Ok(());152
}153
document["showAddresses"] = monitor["showAddresses"].clone();154
document["intervalSecs"] = monitor["intervalSecs"].clone();155
return Ok(crate::core::storage::atomic_write_file(&path, format!("{document:#}\n").as_bytes(), 0o600)?);156
};157
let Some(document) = servers_document(&settings, profiles) else { return Ok(()) };158
let text = format!("{document:#}\n");159
if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {160
crate::core::storage::atomic_write_file(&path, text.as_bytes(), 0o600)?;161
}162
Ok(())163
}165
/// Background server monitoring is hidden in Settings (owner decision 2026-11-07) and therefore off: a stored166
/// `monitor.enabled: true` from earlier is ignored, so `sync_servers` removes the server list and the helper stops167
/// monitoring. Set to `true` to offer the feature again.168
pub const MONITOR_AVAILABLE: bool = false;170
pub fn load(data: &Path) -> Value {171
load_at(data, MONITOR_AVAILABLE)172
}174
fn load_at(data: &Path, monitor_available: bool) -> Value {175
let mut settings = normalize(&std::fs::read(data.join(FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null));176
if !monitor_available {177
settings["monitor"]["enabled"] = json!(false);178
}179
settings180
}182
/// The helper is on after installation (owner decision 2026-11-07): when no settings file exists yet, the first183
/// start of the installed application writes one with the helper enabled (menu entries and start at login on).184
/// An existing file, also one the user turned off, is never changed. Returns whether it wrote the file.185
pub fn enable_on_first_start(data: &Path) -> bool {186
if data.join(FILE).exists() {187
return false;188
}189
save(data, &json!({"enabled": true})).is_ok()190
}192
pub fn save(data: &Path, settings: &Value) -> CoreResult<Value> {193
let normalized = normalize(settings);194
crate::core::storage::atomic_write_file(&data.join(FILE), format!("{normalized:#}\n").as_bytes(), 0o600)?;195
Ok(normalized)196
}198
/// Runs the helper with the application's data folder and returns its JSON status line.199
fn run(data: &Path, command: &str) -> CoreResult<Value> {200
let helper = helper_binary();201
if !helper.is_file() {202
return Err(CoreError::new("STANDBY_HELPER_MISSING", "The standby helper is not installed next to the application."));203
}204
let mut process = std::process::Command::new(&helper);205
process.arg(command).env("CHJ_USER_DATA_DIR", data).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());206
#[cfg(windows)]207
{208
use std::os::windows::process::CommandExt;209
process.creation_flags(0x0800_0000 /* CREATE_NO_WINDOW */);210
}211
let mut child = process.spawn().map_err(|e| CoreError::new("STANDBY_HELPER_FAILED", format!("The standby helper could not start: {e}")))?;212
let mut stdout = child.stdout.take();213
let reader = std::thread::spawn(move || {214
use std::io::Read;215
let mut text = String::new();216
if let Some(out) = stdout.as_mut() {217
let _ = out.take(256 * 1024).read_to_string(&mut text);218
}219
text220
});221
let started = std::time::Instant::now();222
loop {223
if child.try_wait()?.is_some() {224
break;225
}226
if started.elapsed() > Duration::from_secs(20) {227
let _ = child.kill();228
return Err(CoreError::new("STANDBY_HELPER_TIMEOUT", "The standby helper did not answer in time."));229
}230
std::thread::sleep(Duration::from_millis(25));231
}232
let text = reader.join().unwrap_or_default();233
let line = text.lines().rev().find(|l| l.trim_start().starts_with('{')).unwrap_or("");234
serde_json::from_str(line).map_err(|_| CoreError::new("STANDBY_HELPER_FAILED", "The standby helper returned no status."))235
}237
pub fn status(data: &Path) -> Value {238
match run(data, "status") {239
Ok(status) => status,240
Err(error) => json!({"error": error.code, "message": error.message}),241
}242
}244
pub fn apply(data: &Path) -> CoreResult<Value> {245
run(data, "apply")246
}248
/// Stops a running helper that uses `data` (its system entries stay).249
pub fn stop(data: &Path) -> CoreResult<Value> {250
run(data, "stop")251
}253
#[cfg(test)]254
mod tests {255
use super::*;257
#[test]258
fn normalize_keeps_only_known_switches_with_safe_defaults() {259
let monitor_off = json!({"enabled":false,"intervalSecs":60,"check":"ssh","customPort":8443,"showAddresses":true,"servers":{}});260
assert_eq!(normalize(&Value::Null), json!({"enabled":false,"startAtLogin":true,"menu":{"verify":true,"sign":true},"monitor":monitor_off}));261
let input = json!({"enabled":true,"menu":{"sign":false,"x":1},"command":"rm -rf /","startAtLogin":"yes","monitor":{"enabled":true,"intervalSecs":7,"check":"ftp","customPort":70000,"x":1}});262
assert_eq!(normalize(&input), json!({"enabled":true,"startAtLogin":true,"menu":{"verify":true,"sign":false},"monitor":{"enabled":true,"intervalSecs":60,"check":"ssh","customPort":8443,"showAddresses":true,"servers":{}}}));263
let dir = tempfile::tempdir().unwrap();264
assert_eq!(load(dir.path())["enabled"], false);265
save(dir.path(), &input).unwrap();266
assert_eq!(load_at(dir.path(), true), normalize(&input));267
assert_eq!(load(dir.path())["monitor"]["enabled"], json!(MONITOR_AVAILABLE), "hidden monitoring is off");268
assert!(!std::fs::read_to_string(dir.path().join(FILE)).unwrap().contains("rm -rf"));269
}271
#[test]272
fn servers_file_holds_only_routing_fields_follows_the_check_type_and_is_removed_when_off() {273
let profiles = vec![274
json!({"id":"p1","label":"Web","host":"web.example","port":2222,"username":"root","password":"pw","authMethod":"password"}),275
json!({"id":"p2","label":"","host":"10.0.0.5","port":22}),276
json!({"id":"p3","label":"Behind jump","host":"10.9.9.9","port":22,"jumpProfileId":"p1"}),277
json!({"id":"","host":"broken","port":22}),278
];279
let dir = tempfile::tempdir().unwrap();280
let path = dir.path().join(SERVERS_FILE);281
let on = |check: &str| json!({"enabled":true,"monitor":{"enabled":true,"check":check,"customPort":9000,"intervalSecs":30,"showAddresses":false}});282
save(dir.path(), &on("ssh")).unwrap();283
sync_servers_at(dir.path(), Some(&profiles), true).unwrap();284
let text = std::fs::read_to_string(&path).unwrap();285
assert!(!text.contains("root") && !text.contains("pw") && !text.contains("password") && !text.contains("10.9.9.9"), "{text}");286
let doc: Value = serde_json::from_str(&text).unwrap();287
assert_eq!(doc["servers"], json!([{"id":"p1:ssh:2222","name":"Web","host":"web.example","port":2222,"protocol":"ssh"},{"id":"p2:ssh:22","name":"10.0.0.5","host":"10.0.0.5","port":22,"protocol":"ssh"}]));288
assert_eq!((doc["intervalSecs"].as_u64(), doc["showAddresses"].as_bool()), (Some(30), Some(false)));289
#[cfg(unix)]290
{291
use std::os::unix::fs::PermissionsExt;292
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);293
}294
assert_eq!(servers_document(&on("https"), &profiles).unwrap()["servers"][0], json!({"id":"p1:https:443","name":"Web","host":"web.example","port":443,"protocol":"https"}));295
assert_eq!(servers_document(&on("custom"), &profiles).unwrap()["servers"][1]["port"], 9000);296
// Locked vault: the last list stays so the helper keeps monitoring with the app closed.297
sync_servers_at(dir.path(), None, true).unwrap();298
assert!(path.is_file());299
// Hiding addresses while locked still reaches the helper's input.300
save(dir.path(), &json!({"enabled":true,"monitor":{"enabled":true,"showAddresses":false,"intervalSecs":300}})).unwrap();301
sync_servers_at(dir.path(), None, true).unwrap();302
let locked: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();303
assert_eq!((locked["showAddresses"].as_bool(), locked["intervalSecs"].as_u64(), locked["servers"].as_array().map(Vec::len)), (Some(false), Some(300), Some(2)));304
save(dir.path(), &json!({"enabled":true,"monitor":{"enabled":false}})).unwrap();305
sync_servers_at(dir.path(), None, true).unwrap();306
assert!(!path.exists(), "turning monitoring off removes the address list");307
}309
#[test]310
fn helper_is_enabled_on_first_start_but_a_stored_choice_is_kept() {311
let dir = tempfile::tempdir().unwrap();312
assert!(enable_on_first_start(dir.path()));313
let first = load(dir.path());314
assert_eq!((first["enabled"].as_bool(), first["startAtLogin"].as_bool(), first["menu"]["verify"].as_bool(), first["menu"]["sign"].as_bool()), (Some(true), Some(true), Some(true), Some(true)));315
save(dir.path(), &json!({"enabled": false})).unwrap();316
assert!(!enable_on_first_start(dir.path()), "an existing file is never changed");317
assert_eq!(load(dir.path())["enabled"], false);318
}320
#[test]321
fn hidden_monitoring_ignores_a_stored_switch_and_removes_the_server_list() {322
let dir = tempfile::tempdir().unwrap();323
let profiles = vec![json!({"id":"p1","label":"Web","host":"web.example","port":22})];324
save(dir.path(), &json!({"enabled":true,"monitor":{"enabled":true}})).unwrap();325
sync_servers_at(dir.path(), Some(&profiles), true).unwrap();326
assert!(dir.path().join(SERVERS_FILE).is_file());327
assert_eq!(load_at(dir.path(), false)["monitor"]["enabled"], false);328
sync_servers_at(dir.path(), None, false).unwrap();329
assert!(!dir.path().join(SERVERS_FILE).exists(), "the helper gets no server list while monitoring is hidden");330
}332
#[test]333
fn chosen_servers_get_their_own_checks_and_unchosen_ones_are_left_out() {334
let profiles = vec![json!({"id":"p1","label":"Web","host":"web.example","port":2222}), json!({"id":"p2","label":"NAS","host":"10.0.0.5","port":22}), json!({"id":"p3","label":"Off","host":"10.0.0.6","port":22})];335
let settings = json!({"enabled":true,"monitor":{"enabled":true,"servers":{336
"p1":{"checks":[{"type":"https"},{"type":"ssh"},{"type":"smb","port":21},{"type":"dns"},{"type":"tcp"},{"type":"gopher","port":70}]},337
"p3":{"enabled":false,"checks":[{"type":"ssh"}]}}}});338
let normalized = normalize(&settings);339
assert_eq!(normalized["monitor"]["servers"]["p1"]["checks"].as_array().unwrap().len(), 4, "tcp without port and unknown types are dropped");340
let doc = servers_document(&settings, &profiles).unwrap();341
let entries: Vec<(String, u64, String)> = doc["servers"].as_array().unwrap().iter().map(|e| (e["id"].as_str().unwrap().to_string(), e["port"].as_u64().unwrap(), e["protocol"].as_str().unwrap().to_string())).collect();342
assert_eq!(entries, vec![343
("p1:https:443".into(), 443, "https".into()), ("p1:ssh:2222".into(), 2222, "ssh".into()), ("p1:smb:21".into(), 21, "smb".into()), ("p1:dns:53".into(), 53, "dns".into()),344
("p2:ssh:22".into(), 22, "ssh".into()),345
]);346
}347
}SHA-256
1fcb5ff8431f8fe33018f923b80403347edd2bf000c542c74b1f9061d393b5c9CH-J Proprietary Software License 1.14 · Lähde ilmoitetaan CH-J Proprietary Software License 1.14-koodilla. Sen saatavuus ei muuta lisenssiehtoja eikä myönnä lisäoikeuksia.
Arkisto SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f