Skip to main content

cloudpub_client/
config.rs

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
30/// The platform entry that describes this architecture (CLOUDPUB-610).
31///
32/// Architecture, not pointer width: both x86_64 and aarch64 are 64-bit, and
33/// picking on width alone is what sent 64-bit ARM agents the x86_64 httpd
34/// bundle. Anything unrecognised keeps the historical answer - the download
35/// then fails as it did before, rather than the agent refusing to start.
36pub fn platform_for_arch(arch: &str) -> Platform {
37    match arch {
38        "aarch64" => Platform::Aarch64,
39        // std::env::consts::ARCH for every 32-bit ARM, armv7 included.
40        "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        // On Linux the architecture decides, and the entries carry no
57        // target_arch gate, so the whole table is present at runtime and a
58        // unit test on an x86_64 host can check the choice itself
59        // (CLOUDPUB-610). The other systems keep the pointer-width answer:
60        // their tables describe one architecture each - macOS in particular
61        // has a single entry that covers Apple Silicon too.
62        #[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        // No target_arch gate on these, deliberately: the table is data, and
107        // keeping all of it compiled everywhere is what lets the choice be
108        // tested on any host (CLOUDPUB-610). Each architecture unpacks into a
109        // directory of its own - setup_httpd reads installed.txt inside it as
110        // "already unpacked" without checking which bundle put it there, so a
111        // shared directory would serve one architecture's binaries to another.
112        #[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    // This fields are not persistent
189    #[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    /// The httpd bundle and the VC redistributable are fetched by the agent
343    /// from its own server (`{server}download/{file}`, see plugins/httpd.rs),
344    /// so an on-prem image has to ship every file this table names. It used to
345    /// ship none of them: the bundle was downloaded into the Dockerfile's test
346    /// stage only, and WebDAV publication failed with "Failed to setup plugin"
347    /// on a fresh deployment (CLOUDPUB-1231).
348    ///
349    /// The literals are read out of this file's own source rather than out of
350    /// ENV_CONFIG, because the entries are `cfg(target_os)` gated and only the
351    /// host platform's one exists at runtime.
352    /// STATUS: FIXED - see the client release artifacts step in the `server`
353    /// stage of server/Dockerfile
354    /// Every bundle the platform table names.
355    ///
356    /// The literals are read out of this file's own source rather than out of
357    /// ENV_CONFIG, because the entries are `cfg(target_os)` gated and only the
358    /// host platform's one exists at runtime.
359    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    /// The bundle an agent asks for is chosen by architecture, not by pointer
376    /// width - see CLOUDPUB-610.
377    ///
378    /// Before this, Platform knew only X64 and X32 and Default picked between
379    /// them on target_pointer_width, so a 64-bit ARM agent - Raspberry Pi 4/5,
380    /// an ARM NAS, the latest-arm64 container - asked for the x86_64 bundle
381    /// and got an httpd it could not execute. WebDAV was simply unavailable
382    /// there.
383    ///
384    /// The linux entries carry no target_arch gate, which is what lets this
385    /// test read all of them on an x86_64 host and check the choice itself
386    /// rather than the one entry the host happens to compile.
387    #[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        // std::env::consts::ARCH is "arm" for 32-bit ARM, armv7 included.
392        assert_eq!(platform_for_arch("arm"), Platform::Arm);
393    }
394
395    /// An unknown architecture keeps the historical answer rather than
396    /// refusing to start: the worst case is the download failing, which is
397    /// where it was before this change anyway.
398    #[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        // The cache directory must differ per architecture. setup_httpd takes
414        // installed.txt inside it as "already unpacked" and never looks at
415        // which bundle produced it, so a shared directory would hand an ARM
416        // agent the x86_64 binaries left by an earlier attempt.
417        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    /// The bundles have to reach the image from the package registry, not from
445    /// the public site. CLOUDPUB-1251 moved every other per-build download into
446    /// `build-deps/1` because the build host's link kept cutting slow HTTPS
447    /// transfers; this block predates that move and was missed, so it stayed the
448    /// one place a build still depended on cloudpub.ru being reachable - and it
449    /// is what killed build #885 with `SSL_read: unexpected eof`
450    /// (CLOUDPUB-1274).
451    /// STATUS: FIXED - see CLOUDPUB-1274
452    #[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        // The Dockerfile only COPYs what `clo gitlab fetch-deps` staged.
473        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}