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 transport: TransportConfig,
206}
207
208impl ClientConfig {
209 pub fn get_config_path(&self) -> &PathBuf {
210 &self.config_path
211 }
212
213 pub fn from_file(path: &PathBuf, readonly: bool) -> Result<Self> {
214 if !path.exists() && !readonly {
215 let default_config = Self::default();
216 debug!("Creating default config at {:?}", default_config);
217 let s = toml::to_string_pretty(&default_config)
218 .context("Failed to serialize the default config")?;
219 fs::write(path, s).context("Failed to write the default config")?;
220 }
221
222 let s: String = fs::read_to_string(path)
223 .with_context(|| format!("Failed to read the config {:?}", path))?;
224 let mut cfg: Self = toml::from_str(&s).with_context(|| {
225 "Configuration is invalid. Please refer to the configuration specification."
226 })?;
227
228 cfg.config_path = path.clone();
229 cfg.readonly = readonly;
230 Ok(cfg)
231 }
232
233 pub fn get_config_dir(user_dir: bool) -> Result<PathBuf> {
234 let dir = if user_dir {
235 let mut dir = dirs::config_dir().context("Can't get config_dir")?;
236 dir.push("cloudpub");
237 dir
238 } else if cfg!(target_family = "unix") {
239 PathBuf::from("./etc/cloudpub")
240 } else {
241 PathBuf::from("C:\\Windows\\system32\\config\\systemprofile\\AppData\\Local\\cloudpub")
242 };
243 if !dir.exists() {
244 create_dir_all(&dir).context("Can't create config dir")?;
245 }
246 debug!("Config dir: {:?}", dir);
247 Ok(dir)
248 }
249
250 pub fn save(&self) -> Result<()> {
251 if self.readonly {
252 debug!("Skipping saving the config in readonly mode");
253 } else {
254 let s = toml::to_string_pretty(self).context("Failed to serialize the config")?;
255 let mut f = File::create(&self.config_path)?;
256 f.write_all(s.as_bytes())?;
257 f.sync_all()?;
258 }
259 Ok(())
260 }
261
262 pub fn load(cfg_name: &str, user_dir: bool, readonly: bool) -> Result<Self> {
263 let mut config_path = Self::get_config_dir(user_dir)?;
264 config_path.push(cfg_name);
265
266 Self::from_file(&config_path, readonly)
267 }
268
269 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
270 let option = ConfigOption::from_str(key)?;
271 self.set_option(option, value)
272 }
273
274 pub fn get(&self, key: &str) -> Result<String> {
275 let option = ConfigOption::from_str(key)?;
276 self.get_option(option)
277 }
278
279 pub fn get_all_options(&self) -> Vec<(String, String)> {
280 self.get_all_config_options()
281 }
282
283 pub fn validate(&self) -> Result<()> {
284 if self.token.is_none() {
285 bail!("{}", crate::t!("error-auth-missing"));
286 }
287 TransportConfig::validate(&self.transport, false)?;
288 Ok(())
289 }
290
291 #[cfg(not(target_os = "android"))]
292 pub fn get_hwid(&self) -> String {
293 self.hwid
294 .as_ref()
295 .unwrap_or(
296 &IdBuilder::new(Encryption::SHA256)
297 .add_component(HWIDComponent::OSName)
298 .add_component(HWIDComponent::SystemID)
299 .add_component(HWIDComponent::MachineName)
300 .add_component(HWIDComponent::CPUID)
301 .build("cloudpub")
302 .unwrap_or(self.agent_id.clone()),
303 )
304 .to_string()
305 }
306
307 #[cfg(target_os = "android")]
308 pub fn get_hwid(&self) -> String {
309 self.hwid.as_ref().unwrap_or(&self.agent_id).to_string()
310 }
311}
312
313impl Default for ClientConfig {
314 fn default() -> Self {
315 Self {
316 agent_id: Uuid::new_v4().to_string(),
317 hwid: None,
318 config_path: PathBuf::new(),
319 server: format!("https://{}:{}", DOMAIN.as_str(), *PORT)
320 .parse()
321 .unwrap(),
322 token: None,
323 heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
324 one_c_home: None,
325 one_c_platform: None,
326 one_c_publish_dir: None,
327 minecraft_server: None,
328 minecraft_java_opts: None,
329 minimize_to_tray_on_close: Some(true),
330 minimize_to_tray_on_start: Some(false),
331 transport: TransportConfig::default(),
332 readonly: false,
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{platform_for_arch, Platform, ENV_CONFIG};
340 use regex::Regex;
341
342 fn plugin_bundles() -> Vec<&'static str> {
360 let config = include_str!("config.rs");
361 let literal = Regex::new(r#"(?:httpd|redist):\s*"([^"]+)"#).unwrap();
362 let bundles: Vec<&str> = literal
363 .captures_iter(config)
364 .filter_map(|c| c.get(1))
365 .map(|m| m.as_str())
366 .filter(|name| !name.is_empty())
367 .collect();
368 assert!(
369 bundles.len() >= 6,
370 "expected the platform table to name at least 6 bundles, found {bundles:?}"
371 );
372 bundles
373 }
374
375 #[test]
388 fn each_linux_arch_maps_to_its_own_platform() {
389 assert_eq!(platform_for_arch("x86_64"), Platform::X64);
390 assert_eq!(platform_for_arch("aarch64"), Platform::Aarch64);
391 assert_eq!(platform_for_arch("arm"), Platform::Arm);
393 }
394
395 #[test]
399 fn an_unknown_arch_falls_back_to_x64() {
400 assert_eq!(platform_for_arch("riscv64"), Platform::X64);
401 }
402
403 #[cfg(target_os = "linux")]
404 #[test]
405 fn every_linux_arch_has_a_bundle_and_a_cache_dir_of_its_own() {
406 let x64 = ENV_CONFIG.get(&Platform::X64).unwrap();
407 let aarch64 = ENV_CONFIG.get(&Platform::Aarch64).unwrap();
408 let arm = ENV_CONFIG.get(&Platform::Arm).unwrap();
409
410 assert_eq!(aarch64.httpd, "httpd-2.4.68-aarch64-linux.zip");
411 assert_eq!(arm.httpd, "httpd-2.4.68-arm-linux.zip");
412
413 assert_eq!(x64.httpd_dir, "httpd-x64");
418 assert_eq!(aarch64.httpd_dir, "httpd-aarch64");
419 assert_eq!(arm.httpd_dir, "httpd-arm");
420 let dirs = [&x64.httpd_dir, &aarch64.httpd_dir, &arm.httpd_dir];
421 assert_eq!(
422 dirs.iter().collect::<std::collections::HashSet<_>>().len(),
423 3,
424 "two architectures share a cache directory"
425 );
426 }
427
428 #[test]
429 fn every_plugin_bundle_is_shipped_in_the_server_image() {
430 let dockerfile = include_str!("../../server/Dockerfile");
431 let wanted = plugin_bundles();
432
433 let missing: Vec<&&str> = wanted
434 .iter()
435 .filter(|name| !dockerfile.contains(**name))
436 .collect();
437
438 assert!(
439 missing.is_empty(),
440 "server/Dockerfile does not fetch {missing:?}; an agent on that \
441 platform cannot install the httpd plugin from an on-prem server"
442 );
443 }
444 #[test]
453 fn the_plugin_bundles_come_from_the_package_registry() {
454 let dockerfile = include_str!("../../server/Dockerfile");
455 let gitlab = include_str!("../../bin/gitlab");
456
457 let missing: Vec<&str> = plugin_bundles()
458 .into_iter()
459 .filter(|name| !dockerfile.contains(&format!("build-deps/{name}")))
460 .collect();
461 assert!(
462 missing.is_empty(),
463 "server/Dockerfile does not COPY {missing:?} out of build-deps; \
464 the build depends on the public site being reachable"
465 );
466
467 assert!(
468 !dockerfile.contains("cloudpub.ru/download/"),
469 "server/Dockerfile still fetches from cloudpub.ru during the build"
470 );
471
472 let unstaged: Vec<&str> = plugin_bundles()
474 .into_iter()
475 .filter(|name| !gitlab.contains(&format!("\"{name}\"")))
476 .collect();
477 assert!(
478 unstaged.is_empty(),
479 "bin/gitlab BUILD_DEPS does not fetch {unstaged:?}, so the COPY \
480 has nothing to copy"
481 );
482 }
483}