CH-J Toolspamamahala ng server sa pamamagitan ng SSH
Menu
Nailathalang source

CH-J Tools

I-browse ang mga direktoryo at file ng isang partikular na release ng application.

I-download ang source ZIP
CH-J / Server Manager 1704 file
0.1.2-beta Idinagdag: · Binuo:
standby.rs Idinagdag: Hindi naitala ang petsa 19.1 KB · 347 linya
Code I-download ang file
1//! Settings of the separate `ch-j-standby-helper` (see `src/standby/`). The application only writes the helper's
2//! switches to `<data>/standby-helper.json` and runs `ch-j-standby-helper apply|status`; the helper owns every
3//! system entry (menu items, login item, background process). The file holds no secrets.
4use crate::core::error::{CoreError, CoreResult};
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7use std::time::Duration;
9pub 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`.
13pub const MAC_HELPER_IN_BUNDLE: &str = "../Library/LoginItems/CH-J-Standby-Helper.app/Contents/MacOS/ch-j-standby-helper";
15pub 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)
25/// Only the known switches survive; anything else in the input is dropped.
26pub 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 })
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.
52pub const CHECK_TYPES: [(&str, u16); 8] = [("ssh", 22), ("https", 443), ("http", 80), ("smb", 445), ("ftp", 21), ("dns", 53), ("rdp", 3389), ("tcp", 0)];
53const MAX_CHECKS_PER_SERVER: usize = 8;
54const 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.
58fn 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)
83/// Background availability monitoring by the helper (`standby-helper/src/monitor.rs`). The helper never reads the
84/// 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 exception
86/// to "availability state lives only in memory while the vault is unlocked".
87pub const SERVERS_FILE: &str = "standby-servers.json";
88pub const MONITOR_INTERVALS: [u64; 3] = [30, 60, 300];
89const 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.
93pub 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> = profiles
105 .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}))
130/// Brings `standby-servers.json` in line with the settings. `profiles` is `None` while the vault is locked: the
131/// last written list then stays (monitoring continues with the app closed); only "off" removes it.
132pub fn sync_servers(data: &Path, profiles: Option<&[Value]>) -> CoreResult<()> {
133 sync_servers_at(data, profiles, MONITOR_AVAILABLE)
136fn 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 helper
147 // (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(())
165/// Background server monitoring is hidden in Settings (owner decision 2026-11-07) and therefore off: a stored
166/// `monitor.enabled: true` from earlier is ignored, so `sync_servers` removes the server list and the helper stops
167/// monitoring. Set to `true` to offer the feature again.
168pub const MONITOR_AVAILABLE: bool = false;
170pub fn load(data: &Path) -> Value {
171 load_at(data, MONITOR_AVAILABLE)
174fn 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 settings
182/// The helper is on after installation (owner decision 2026-11-07): when no settings file exists yet, the first
183/// 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.
185pub 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()
192pub 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)
198/// Runs the helper with the application's data folder and returns its JSON status line.
199fn 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 text
220 });
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."))
237pub 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 }
244pub fn apply(data: &Path) -> CoreResult<Value> {
245 run(data, "apply")
248/// Stops a running helper that uses `data` (its system entries stay).
249pub fn stop(data: &Path) -> CoreResult<Value> {
250 run(data, "stop")
253#[cfg(test)]
254mod 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 }
SHA-2561fcb5ff8431f8fe33018f923b80403347edd2bf000c542c74b1f9061d393b5c9

CH-J Proprietary Software License 1.14 · Ibinibigay ang source sa ilalim ng CH-J Proprietary Software License 1.14. Hindi binabago ng pagiging available nito ang mga tuntunin ng lisensya at hindi ito nagbibigay ng karagdagang karapatan.

SHA-256 ng archiveac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Mga file