1use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Config {
16 #[serde(default)]
17 pub token: String,
18 #[serde(default)]
19 pub user_id: String,
20 #[serde(default = "default_relay_url")]
21 pub relay_url: String,
22 #[serde(default)]
23 pub hostname: String,
24 #[serde(default)]
25 pub active_device_id: String,
26 #[serde(default)]
27 pub credential_version: u64,
28 #[serde(default)]
29 pub encryption_key: String,
30 #[serde(default)]
31 pub device_private_key: String,
32 #[serde(default)]
33 pub email: String,
34 #[serde(default)]
35 pub identity_provider: String,
36}
37
38pub fn default_relay_url() -> String {
39 "http://localhost:8080".to_string()
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RelayProfile {
44 pub id: String,
45 pub label: String,
46 pub relay_url: String,
47 pub user_id: String,
48 pub device_id: String,
49 pub hostname: String,
50 #[serde(default)]
51 pub encryption_key: String,
52 #[serde(default)]
53 pub device_private_key: String,
54 #[serde(default)]
55 pub credential_version: u64,
56 #[serde(default)]
57 pub token: String,
58 #[serde(default)]
63 pub machine_id: String,
64 #[serde(default)]
67 pub email: String,
68 #[serde(default)]
71 pub identity_provider: String,
72}
73
74impl RelayProfile {
75 pub fn from_config(cfg: &Config, label: Option<String>) -> Self {
76 use ulid::Ulid;
77 let id = Ulid::new().to_string();
78 let label = label.unwrap_or_else(|| {
79 url::Url::parse(&cfg.relay_url)
80 .ok()
81 .and_then(|u| u.host_str().map(|h| h.to_string()))
82 .unwrap_or_else(|| cfg.relay_url.clone())
83 });
84 Self {
85 id,
86 label,
87 relay_url: cfg.relay_url.clone(),
88 user_id: cfg.user_id.clone(),
89 device_id: cfg.active_device_id.clone(),
90 hostname: cfg.hostname.clone(),
91 encryption_key: cfg.encryption_key.clone(),
92 device_private_key: cfg.device_private_key.clone(),
93 credential_version: cfg.credential_version,
94 token: cfg.token.clone(),
95 machine_id: crate::machine::stable_machine_id(),
96 email: cfg.email.clone(),
97 identity_provider: cfg.identity_provider.clone(),
98 }
99 }
100
101 pub fn to_config(&self) -> Config {
102 Config {
103 token: self.token.clone(),
104 user_id: self.user_id.clone(),
105 relay_url: self.relay_url.clone(),
106 hostname: self.hostname.clone(),
107 active_device_id: self.device_id.clone(),
108 credential_version: self.credential_version,
109 encryption_key: self.encryption_key.clone(),
110 device_private_key: self.device_private_key.clone(),
111 email: self.email.clone(),
112 identity_provider: self.identity_provider.clone(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct MultiConfig {
119 #[serde(default)]
120 pub active_relay_id: Option<String>,
121 #[serde(default)]
122 pub relays: Vec<RelayProfile>,
123}
124
125pub type MultiConfigHandle = Arc<Mutex<MultiConfig>>;
126
127impl MultiConfig {
128 pub fn load() -> Self {
129 let Some(home) = dirs::home_dir() else {
130 return Self::default();
131 };
132 let path = home.join(".cinch").join("config.json");
133 if !path.exists() {
134 return Self::default();
135 }
136 let Ok(data) = std::fs::read_to_string(&path) else {
137 return Self::default();
138 };
139 let Ok(v) = serde_json::from_str::<serde_json::Value>(&data) else {
140 return Self::default();
141 };
142 if v.get("relays").is_some() {
143 serde_json::from_value(v).unwrap_or_default()
144 } else {
145 let old: Config = match serde_json::from_value(v) {
146 Ok(c) => c,
147 Err(_) => return Self::default(),
148 };
149 Self::from_legacy(old)
150 }
151 }
152
153 pub fn save(&self) -> Result<(), String> {
154 let home = dirs::home_dir().ok_or("cannot determine home directory")?;
155 let dir = home.join(".cinch");
156 std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {}", e))?;
157 let path = dir.join("config.json");
158 let data = serde_json::to_string_pretty(self).map_err(|e| format!("marshal: {}", e))?;
159 std::fs::write(&path, &data).map_err(|e| format!("write: {}", e))?;
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::PermissionsExt;
163 if let Ok(meta) = std::fs::metadata(&path) {
164 let mut perms = meta.permissions();
165 perms.set_mode(0o600);
166 let _ = std::fs::set_permissions(&path, perms);
167 }
168 }
169 Ok(())
170 }
171
172 pub fn active_profile(&self) -> Option<&RelayProfile> {
173 let id = self.active_relay_id.as_deref()?;
174 self.relays.iter().find(|r| r.id == id)
175 }
176
177 pub fn active_profile_mut(&mut self) -> Option<&mut RelayProfile> {
178 let id = self.active_relay_id.clone()?;
179 self.relays.iter_mut().find(|r| r.id == id)
180 }
181
182 pub fn to_active_config(&self) -> Config {
183 self.active_profile()
184 .map(|p| p.to_config())
185 .unwrap_or_default()
186 }
187
188 pub fn from_legacy_pub(old: Config) -> Self {
189 Self::from_legacy(old)
190 }
191
192 fn from_legacy(old: Config) -> Self {
193 if old.user_id.is_empty() && old.token.is_empty() {
194 return Self::default();
195 }
196 let profile = RelayProfile::from_config(&old, None);
197 let id = profile.id.clone();
198 Self {
199 active_relay_id: Some(id),
200 relays: vec![profile],
201 }
202 }
203}
204
205impl Default for Config {
206 fn default() -> Self {
207 Self {
208 token: String::new(),
209 user_id: String::new(),
210 relay_url: default_relay_url(),
211 hostname: String::new(),
212 active_device_id: String::new(),
213 credential_version: 0,
214 encryption_key: String::new(),
215 device_private_key: String::new(),
216 email: String::new(),
217 identity_provider: String::new(),
218 }
219 }
220}
221
222impl Config {
223 pub fn is_configured(&self) -> bool {
224 !self.user_id.is_empty() && !self.active_device_id.is_empty()
225 }
226
227 pub fn load() -> Result<Self, String> {
228 let mc = MultiConfig::load();
229 let cfg = mc.to_active_config();
230 if cfg.user_id.is_empty() && cfg.token.is_empty() {
231 return Err("no active relay configured — run: cinch auth login".to_string());
232 }
233 Ok(cfg)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_is_configured_accepts_keyring_backed_config() {
243 let config = Config {
244 token: String::new(),
245 user_id: "u1".into(),
246 relay_url: "https://api.cinchcli.com".into(),
247 hostname: "macbook".into(),
248 active_device_id: "d1".into(),
249 credential_version: 1,
250 encryption_key: String::new(),
251 device_private_key: String::new(),
252 email: String::new(),
253 identity_provider: String::new(),
254 };
255 assert!(config.is_configured());
256 }
257}