1use anyhow::{bail, Context, Result};
2use cloudpub_common::constants::DEFAULT_HEARTBEAT_TIMEOUT_SECS;
3use cloudpub_common::{DOMAIN, PORT};
4use serde::{Deserialize, Serialize};
5use std::fmt::{Debug, Display, Formatter};
6use std::str::FromStr;
7
8use crate::options::ConfigOption;
9pub use cloudpub_common::config::{MaskedString, TransportConfig};
10use lazy_static::lazy_static;
11#[cfg(not(target_os = "android"))]
12use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
13use std::collections::HashMap;
14use std::fs::{self, create_dir_all, File};
15use std::io::Write;
16use std::path::PathBuf;
17use tracing::debug;
18use url::Url;
19use uuid::Uuid;
20
21#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
22pub enum Platform {
23 #[default]
24 X64,
25 X32,
26 Aarch64,
27 Arm,
28}
29
30pub fn platform_for_arch(arch: &str) -> Platform {
37 match arch {
38 "aarch64" => Platform::Aarch64,
39 "arm" => Platform::Arm,
41 _ => Platform::X64,
42 }
43}
44
45#[derive(Clone)]
46pub struct EnvConfig {
47 pub home_1c: PathBuf,
48 #[cfg(target_os = "windows")]
49 pub redist: String,
50 pub httpd: String,
51 pub httpd_dir: String,
52}
53
54impl Default for EnvConfig {
55 fn default() -> Self {
56 #[cfg(target_os = "linux")]
63 return ENV_CONFIG
64 .get(&platform_for_arch(std::env::consts::ARCH))
65 .unwrap()
66 .clone();
67 #[cfg(all(not(target_os = "linux"), target_pointer_width = "64"))]
68 return ENV_CONFIG.get(&Platform::X64).unwrap().clone();
69 #[cfg(all(not(target_os = "linux"), target_pointer_width = "32"))]
70 return ENV_CONFIG.get(&Platform::X32).unwrap().clone();
71 }
72}
73
74lazy_static! {
75 pub static ref ENV_CONFIG: HashMap<Platform, EnvConfig> = {
76 let mut m = HashMap::new();
77 #[cfg(target_os = "windows")]
78 m.insert(
79 Platform::X64,
80 EnvConfig {
81 home_1c: PathBuf::from("C:\\Program Files\\1cv8\\"),
82 redist: "vc_redist.x64.exe".to_string(),
83 httpd: "httpd-2.4.61-240703-win64-VS17.zip".to_string(),
84 httpd_dir: "httpd-x64".to_string(),
85 },
86 );
87 #[cfg(target_os = "windows")]
88 m.insert(
89 Platform::X32,
90 EnvConfig {
91 home_1c: PathBuf::from("C:\\Program Files (x86)\\1cv8"),
92 redist: "vc_redist.x86.exe".to_string(),
93 httpd: "httpd-2.4.61-240703-win32-vs17.zip".to_string(),
94 httpd_dir: "httpd-x32".to_string(),
95 },
96 );
97 #[cfg(target_os = "linux")]
98 m.insert(
99 Platform::X64,
100 EnvConfig {
101 home_1c: PathBuf::from("/opt/1C"),
102 httpd: "httpd-2.4.68-linux.zip".to_string(),
103 httpd_dir: "httpd-x64".to_string(),
104 },
105 );
106 #[cfg(target_os = "linux")]
113 m.insert(
114 Platform::Aarch64,
115 EnvConfig {
116 home_1c: PathBuf::from("/opt/1C"),
117 httpd: "httpd-2.4.68-aarch64-linux.zip".to_string(),
118 httpd_dir: "httpd-aarch64".to_string(),
119 },
120 );
121 #[cfg(target_os = "linux")]
122 m.insert(
123 Platform::Arm,
124 EnvConfig {
125 home_1c: PathBuf::from("/opt/1C"),
126 httpd: "httpd-2.4.68-arm-linux.zip".to_string(),
127 httpd_dir: "httpd-arm".to_string(),
128 },
129 );
130 #[cfg(target_os = "macos")]
131 m.insert(
132 Platform::X64,
133 EnvConfig {
134 home_1c: PathBuf::from("/Applications/1C"),
135 httpd: "httpd-2.4.62-macos.zip".to_string(),
136 httpd_dir: "httpd-x64".to_string(),
137 },
138 );
139 #[cfg(target_os = "android")]
140 m.insert(
141 Platform::X64,
142 EnvConfig {
143 home_1c: PathBuf::from("/data/local/tmp"),
144 httpd: String::new(),
145 httpd_dir: String::new(),
146 },
147 );
148 m
149 };
150}
151
152impl Display for Platform {
153 fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
154 match self {
155 Platform::X64 => write!(f, "x64"),
156 Platform::X32 => write!(f, "x32"),
157 Platform::Aarch64 => write!(f, "aarch64"),
158 Platform::Arm => write!(f, "arm"),
159 }
160 }
161}
162
163impl std::str::FromStr for Platform {
164 type Err = anyhow::Error;
165
166 fn from_str(s: &str) -> Result<Self> {
167 match s {
168 "x64" => Ok(Platform::X64),
169 "x32" => Ok(Platform::X32),
170 "aarch64" => Ok(Platform::Aarch64),
171 "arm" => Ok(Platform::Arm),
172 _ => bail!("Invalid platform: {}", s),
173 }
174 }
175}
176
177#[derive(Debug, PartialEq, Eq, Clone, Default)]
178pub struct ClientOpts {
179 pub gui: bool,
180 pub credentials: Option<(String, String)>,
181 pub transient: bool,
182 pub secondary: bool,
183 pub is_service: bool,
184}
185
186#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
187pub struct ClientConfig {
188 #[serde(skip)]
190 config_path: PathBuf,
191 #[serde(skip)]
192 pub readonly: bool,
193 pub agent_id: String,
194 pub hwid: Option<String>,
195 pub server: Url,
196 pub token: Option<MaskedString>,
197 pub heartbeat_timeout: u64,
198 pub one_c_home: Option<String>,
199 pub one_c_platform: Option<Platform>,
200 pub one_c_publish_dir: Option<String>,
201 pub minecraft_server: Option<String>,
202 pub minecraft_java_opts: Option<String>,
203 pub minimize_to_tray_on_close: Option<bool>,
204 pub minimize_to_tray_on_start: Option<bool>,
205 pub signed_out: Option<bool>,
207 pub transport: TransportConfig,
208}
209
210impl ClientConfig {
211 pub fn get_config_path(&self) -> &PathBuf {
212 &self.config_path
213 }
214
215 pub fn from_file(path: &PathBuf, readonly: bool) -> Result<Self> {
216 if !path.exists() && !readonly {
217 let default_config = Self::default();
218 debug!("Creating default config at {:?}", default_config);
219 let s = toml::to_string_pretty(&default_config)
220 .context("Failed to serialize the default config")?;
221 fs::write(path, s).context("Failed to write the default config")?;
222 }
223
224 let s: String = fs::read_to_string(path)
225 .with_context(|| format!("Failed to read the config {:?}", path))?;
226 let mut cfg: Self = toml::from_str(&s).with_context(|| {
227 "Configuration is invalid. Please refer to the configuration specification."
228 })?;
229
230 cfg.config_path = path.clone();
231 cfg.readonly = readonly;
232 Ok(cfg)
233 }
234
235 pub fn get_config_dir(user_dir: bool) -> Result<PathBuf> {
236 let dir = if user_dir {
237 let mut dir = dirs::config_dir().context("Can't get config_dir")?;
238 dir.push("cloudpub");
239 dir
240 } else if cfg!(target_family = "unix") {
241 PathBuf::from("./etc/cloudpub")
242 } else {
243 PathBuf::from("C:\\Windows\\system32\\config\\systemprofile\\AppData\\Local\\cloudpub")
244 };
245 if !dir.exists() {
246 create_dir_all(&dir).context("Can't create config dir")?;
247 }
248 debug!("Config dir: {:?}", dir);
249 Ok(dir)
250 }
251
252 pub fn save(&self) -> Result<()> {
253 if self.readonly {
254 debug!("Skipping saving the config in readonly mode");
255 } else {
256 let s = toml::to_string_pretty(self).context("Failed to serialize the config")?;
257 let mut f = File::create(&self.config_path)?;
258 f.write_all(s.as_bytes())?;
259 f.sync_all()?;
260 }
261 Ok(())
262 }
263
264 pub fn load(cfg_name: &str, user_dir: bool, readonly: bool) -> Result<Self> {
265 let mut config_path = Self::get_config_dir(user_dir)?;
266 config_path.push(cfg_name);
267
268 Self::from_file(&config_path, readonly)
269 }
270
271 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
272 let option = ConfigOption::from_str(key)?;
273 self.set_option(option, value)
274 }
275
276 pub fn get(&self, key: &str) -> Result<String> {
277 let option = ConfigOption::from_str(key)?;
278 self.get_option(option)
279 }
280
281 pub fn get_all_options(&self) -> Vec<(String, String)> {
282 self.get_all_config_options()
283 }
284
285 pub fn validate(&self) -> Result<()> {
286 if self.token.is_none() {
287 bail!("{}", crate::t!("error-auth-missing"));
288 }
289 TransportConfig::validate(&self.transport, false)?;
290 Ok(())
291 }
292
293 #[cfg(not(target_os = "android"))]
294 pub fn get_hwid(&self) -> String {
295 self.hwid
296 .as_ref()
297 .unwrap_or(
298 &IdBuilder::new(Encryption::SHA256)
299 .add_component(HWIDComponent::OSName)
300 .add_component(HWIDComponent::SystemID)
301 .add_component(HWIDComponent::MachineName)
302 .add_component(HWIDComponent::CPUID)
303 .build("cloudpub")
304 .unwrap_or(self.agent_id.clone()),
305 )
306 .to_string()
307 }
308
309 #[cfg(target_os = "android")]
310 pub fn get_hwid(&self) -> String {
311 self.hwid.as_ref().unwrap_or(&self.agent_id).to_string()
312 }
313}
314
315impl Default for ClientConfig {
316 fn default() -> Self {
317 Self {
318 agent_id: Uuid::new_v4().to_string(),
319 hwid: None,
320 config_path: PathBuf::new(),
321 server: format!("https://{}:{}", DOMAIN.as_str(), *PORT)
322 .parse()
323 .unwrap(),
324 token: None,
325 heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
326 one_c_home: None,
327 one_c_platform: None,
328 one_c_publish_dir: None,
329 minecraft_server: None,
330 minecraft_java_opts: None,
331 minimize_to_tray_on_close: Some(true),
332 minimize_to_tray_on_start: Some(false),
333 signed_out: None,
334 transport: TransportConfig::default(),
335 readonly: false,
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::{platform_for_arch, Platform, ENV_CONFIG};
343 use regex::Regex;
344
345 fn plugin_bundles() -> Vec<&'static str> {
363 let config = include_str!("config.rs");
364 let literal = Regex::new(r#"(?:httpd|redist):\s*"([^"]+)"#).unwrap();
365 let bundles: Vec<&str> = literal
366 .captures_iter(config)
367 .filter_map(|c| c.get(1))
368 .map(|m| m.as_str())
369 .filter(|name| !name.is_empty())
370 .collect();
371 assert!(
372 bundles.len() >= 6,
373 "expected the platform table to name at least 6 bundles, found {bundles:?}"
374 );
375 bundles
376 }
377
378 #[test]
391 fn each_linux_arch_maps_to_its_own_platform() {
392 assert_eq!(platform_for_arch("x86_64"), Platform::X64);
393 assert_eq!(platform_for_arch("aarch64"), Platform::Aarch64);
394 assert_eq!(platform_for_arch("arm"), Platform::Arm);
396 }
397
398 #[test]
402 fn an_unknown_arch_falls_back_to_x64() {
403 assert_eq!(platform_for_arch("riscv64"), Platform::X64);
404 }
405
406 #[cfg(target_os = "linux")]
407 #[test]
408 fn every_linux_arch_has_a_bundle_and_a_cache_dir_of_its_own() {
409 let x64 = ENV_CONFIG.get(&Platform::X64).unwrap();
410 let aarch64 = ENV_CONFIG.get(&Platform::Aarch64).unwrap();
411 let arm = ENV_CONFIG.get(&Platform::Arm).unwrap();
412
413 assert_eq!(aarch64.httpd, "httpd-2.4.68-aarch64-linux.zip");
414 assert_eq!(arm.httpd, "httpd-2.4.68-arm-linux.zip");
415
416 assert_eq!(x64.httpd_dir, "httpd-x64");
421 assert_eq!(aarch64.httpd_dir, "httpd-aarch64");
422 assert_eq!(arm.httpd_dir, "httpd-arm");
423 let dirs = [&x64.httpd_dir, &aarch64.httpd_dir, &arm.httpd_dir];
424 assert_eq!(
425 dirs.iter().collect::<std::collections::HashSet<_>>().len(),
426 3,
427 "two architectures share a cache directory"
428 );
429 }
430
431 #[test]
432 fn every_plugin_bundle_is_shipped_in_the_server_image() {
433 let dockerfile = include_str!("../../server/Dockerfile");
434 let wanted = plugin_bundles();
435
436 let missing: Vec<&&str> = wanted
437 .iter()
438 .filter(|name| !dockerfile.contains(**name))
439 .collect();
440
441 assert!(
442 missing.is_empty(),
443 "server/Dockerfile does not fetch {missing:?}; an agent on that \
444 platform cannot install the httpd plugin from an on-prem server"
445 );
446 }
447 #[test]
456 fn the_plugin_bundles_come_from_the_package_registry() {
457 let dockerfile = include_str!("../../server/Dockerfile");
458 let gitlab = include_str!("../../bin/gitlab");
459
460 let missing: Vec<&str> = plugin_bundles()
461 .into_iter()
462 .filter(|name| !dockerfile.contains(&format!("build-deps/{name}")))
463 .collect();
464 assert!(
465 missing.is_empty(),
466 "server/Dockerfile does not COPY {missing:?} out of build-deps; \
467 the build depends on the public site being reachable"
468 );
469
470 assert!(
471 !dockerfile.contains("cloudpub.ru/download/"),
472 "server/Dockerfile still fetches from cloudpub.ru during the build"
473 );
474
475 let unstaged: Vec<&str> = plugin_bundles()
477 .into_iter()
478 .filter(|name| !gitlab.contains(&format!("\"{name}\"")))
479 .collect();
480 assert!(
481 unstaged.is_empty(),
482 "bin/gitlab BUILD_DEPS does not fetch {unstaged:?}, so the COPY \
483 has nothing to copy"
484 );
485 }
486}