agentmux_srv/backend/remote/
agent_access.rs1use std::path::{Path, PathBuf};
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20const FILE_NAME: &str = "ssh-agent-access.json";
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23struct Grants {
24 #[serde(default)]
25 always: Vec<Grant>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29struct Grant {
30 agent: String,
31 connection: String,
32}
33
34static LOCK: Mutex<()> = Mutex::new(());
36
37fn path_in(config_home: &Path) -> PathBuf {
38 config_home.join(FILE_NAME)
39}
40
41fn read(path: &Path) -> Grants {
42 std::fs::read_to_string(path)
43 .ok()
44 .and_then(|s| serde_json::from_str(&s).ok())
45 .unwrap_or_default()
46}
47
48pub fn always_allowed_in(config_home: &Path, agent: &str, connection: &str) -> bool {
50 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
51 read(&path_in(config_home))
52 .always
53 .iter()
54 .any(|g| g.agent.eq_ignore_ascii_case(agent) && g.connection == connection.trim())
55}
56
57pub fn remember_in(config_home: &Path, agent: &str, connection: &str) -> std::io::Result<()> {
59 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
60 let path = path_in(config_home);
61 let mut grants = read(&path);
62 let grant = Grant {
63 agent: agent.to_ascii_lowercase(),
64 connection: connection.trim().to_string(),
65 };
66 if !grants.always.contains(&grant) {
67 grants.always.push(grant);
68 }
69 std::fs::create_dir_all(config_home)?;
70 let tmp = path.with_extension("json.tmp");
71 std::fs::write(&tmp, serde_json::to_vec_pretty(&grants).unwrap_or_default())?;
72 std::fs::rename(&tmp, &path)
73}
74
75pub fn always_allowed(agent: &str, connection: &str) -> bool {
77 always_allowed_in(
78 &crate::backend::base::get_mux_config_dir(),
79 agent,
80 connection,
81 )
82}
83
84pub fn remember(agent: &str, connection: &str) -> std::io::Result<()> {
86 remember_in(
87 &crate::backend::base::get_mux_config_dir(),
88 agent,
89 connection,
90 )
91}
92
93pub fn revoke_in(config_home: &Path, agent: &str, connection: &str) -> std::io::Result<bool> {
97 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
98 let path = path_in(config_home);
99 let mut grants = read(&path);
100 let before = grants.always.len();
101 grants
102 .always
103 .retain(|g| !(g.agent.eq_ignore_ascii_case(agent) && g.connection == connection.trim()));
104 if grants.always.len() == before {
105 return Ok(false);
106 }
107 let tmp = path.with_extension("json.tmp");
108 std::fs::write(&tmp, serde_json::to_vec_pretty(&grants).unwrap_or_default())?;
109 std::fs::rename(&tmp, &path)?;
110 Ok(true)
111}
112
113pub fn revoke(agent: &str, connection: &str) -> std::io::Result<bool> {
115 revoke_in(&crate::backend::base::get_mux_config_dir(), agent, connection)
116}
117
118pub fn all_in(config_home: &Path) -> Vec<(String, String)> {
121 let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
122 read(&path_in(config_home))
123 .always
124 .into_iter()
125 .map(|g| (g.agent, g.connection))
126 .collect()
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn always_is_remembered_per_agent_and_host() {
135 let dir = tempfile::tempdir().unwrap();
136 let home = dir.path();
137 assert!(
138 !always_allowed_in(home, "korp", "area54"),
139 "nothing until the user says so"
140 );
141 remember_in(home, "Korp", "area54").unwrap();
142 remember_in(home, "korp", "area54").unwrap();
143 assert!(always_allowed_in(home, "korp", "area54"));
144 assert!(
145 always_allowed_in(home, "KORP", " area54 "),
146 "agent ids ignore case"
147 );
148 assert!(
149 !always_allowed_in(home, "korp", "Area54"),
150 "host names are exact"
151 );
152 assert!(
153 !always_allowed_in(home, "agentx", "area54"),
154 "another agent is asked"
155 );
156 assert!(
157 !always_allowed_in(home, "korp", "nas"),
158 "another host is asked"
159 );
160 let text = std::fs::read_to_string(home.join(FILE_NAME)).unwrap();
161 assert_eq!(text.matches("area54").count(), 1, "stored once: {text}");
162 }
163
164 #[test]
165 fn revoking_asks_again_and_leaves_the_others() {
166 let dir = tempfile::tempdir().unwrap();
167 let home = dir.path();
168 remember_in(home, "korp", "area54").unwrap();
169 remember_in(home, "korp", "nas").unwrap();
170 remember_in(home, "agentx", "area54").unwrap();
171 assert!(revoke_in(home, "KORP", " area54 ").unwrap());
172 assert!(!always_allowed_in(home, "korp", "area54"));
173 assert!(always_allowed_in(home, "korp", "nas"), "another host keeps its grant");
174 assert!(always_allowed_in(home, "agentx", "area54"), "another agent keeps its grant");
175 assert!(!revoke_in(home, "korp", "area54").unwrap(), "nothing left to revoke");
176 }
177
178 #[test]
179 fn an_unreadable_file_grants_nothing() {
180 let dir = tempfile::tempdir().unwrap();
181 std::fs::write(dir.path().join(FILE_NAME), "not json").unwrap();
182 assert!(!always_allowed_in(dir.path(), "korp", "area54"));
183 }
184}