use anyhow::{bail, Context, Result};
use cloudpub_common::constants::DEFAULT_HEARTBEAT_TIMEOUT_SECS;
use cloudpub_common::{DOMAIN, PORT};
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display, Formatter};
use std::str::FromStr;
use crate::options::ConfigOption;
pub use cloudpub_common::config::{MaskedString, TransportConfig};
use lazy_static::lazy_static;
#[cfg(not(target_os = "android"))]
use machineid_rs::{Encryption, HWIDComponent, IdBuilder};
use std::collections::HashMap;
use std::fs::{self, create_dir_all, File};
use std::io::Write;
use std::path::PathBuf;
use tracing::debug;
use url::Url;
use uuid::Uuid;
#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum Platform {
#[default]
X64,
X32,
Aarch64,
Arm,
}
pub fn platform_for_arch(arch: &str) -> Platform {
match arch {
"aarch64" => Platform::Aarch64,
"arm" => Platform::Arm,
_ => Platform::X64,
}
}
#[derive(Clone)]
pub struct EnvConfig {
pub home_1c: PathBuf,
#[cfg(target_os = "windows")]
pub redist: String,
pub httpd: String,
pub httpd_dir: String,
}
impl Default for EnvConfig {
fn default() -> Self {
#[cfg(target_os = "linux")]
return ENV_CONFIG
.get(&platform_for_arch(std::env::consts::ARCH))
.unwrap()
.clone();
#[cfg(all(not(target_os = "linux"), target_pointer_width = "64"))]
return ENV_CONFIG.get(&Platform::X64).unwrap().clone();
#[cfg(all(not(target_os = "linux"), target_pointer_width = "32"))]
return ENV_CONFIG.get(&Platform::X32).unwrap().clone();
}
}
lazy_static! {
pub static ref ENV_CONFIG: HashMap<Platform, EnvConfig> = {
let mut m = HashMap::new();
#[cfg(target_os = "windows")]
m.insert(
Platform::X64,
EnvConfig {
home_1c: PathBuf::from("C:\\Program Files\\1cv8\\"),
redist: "vc_redist.x64.exe".to_string(),
httpd: "httpd-2.4.61-240703-win64-VS17.zip".to_string(),
httpd_dir: "httpd-x64".to_string(),
},
);
#[cfg(target_os = "windows")]
m.insert(
Platform::X32,
EnvConfig {
home_1c: PathBuf::from("C:\\Program Files (x86)\\1cv8"),
redist: "vc_redist.x86.exe".to_string(),
httpd: "httpd-2.4.61-240703-win32-vs17.zip".to_string(),
httpd_dir: "httpd-x32".to_string(),
},
);
#[cfg(target_os = "linux")]
m.insert(
Platform::X64,
EnvConfig {
home_1c: PathBuf::from("/opt/1C"),
httpd: "httpd-2.4.68-linux.zip".to_string(),
httpd_dir: "httpd-x64".to_string(),
},
);
#[cfg(target_os = "linux")]
m.insert(
Platform::Aarch64,
EnvConfig {
home_1c: PathBuf::from("/opt/1C"),
httpd: "httpd-2.4.68-aarch64-linux.zip".to_string(),
httpd_dir: "httpd-aarch64".to_string(),
},
);
#[cfg(target_os = "linux")]
m.insert(
Platform::Arm,
EnvConfig {
home_1c: PathBuf::from("/opt/1C"),
httpd: "httpd-2.4.68-arm-linux.zip".to_string(),
httpd_dir: "httpd-arm".to_string(),
},
);
#[cfg(target_os = "macos")]
m.insert(
Platform::X64,
EnvConfig {
home_1c: PathBuf::from("/Applications/1C"),
httpd: "httpd-2.4.62-macos.zip".to_string(),
httpd_dir: "httpd-x64".to_string(),
},
);
#[cfg(target_os = "android")]
m.insert(
Platform::X64,
EnvConfig {
home_1c: PathBuf::from("/data/local/tmp"),
httpd: String::new(),
httpd_dir: String::new(),
},
);
m
};
}
impl Display for Platform {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Platform::X64 => write!(f, "x64"),
Platform::X32 => write!(f, "x32"),
Platform::Aarch64 => write!(f, "aarch64"),
Platform::Arm => write!(f, "arm"),
}
}
}
impl std::str::FromStr for Platform {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"x64" => Ok(Platform::X64),
"x32" => Ok(Platform::X32),
"aarch64" => Ok(Platform::Aarch64),
"arm" => Ok(Platform::Arm),
_ => bail!("Invalid platform: {}", s),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct ClientOpts {
pub gui: bool,
pub credentials: Option<(String, String)>,
pub transient: bool,
pub secondary: bool,
pub is_service: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
pub struct ClientConfig {
#[serde(skip)]
config_path: PathBuf,
#[serde(skip)]
pub readonly: bool,
pub agent_id: String,
pub hwid: Option<String>,
pub server: Url,
pub token: Option<MaskedString>,
pub heartbeat_timeout: u64,
pub one_c_home: Option<String>,
pub one_c_platform: Option<Platform>,
pub one_c_publish_dir: Option<String>,
pub minecraft_server: Option<String>,
pub minecraft_java_opts: Option<String>,
pub minimize_to_tray_on_close: Option<bool>,
pub minimize_to_tray_on_start: Option<bool>,
pub signed_out: Option<bool>,
pub transport: TransportConfig,
}
impl ClientConfig {
pub fn get_config_path(&self) -> &PathBuf {
&self.config_path
}
pub fn from_file(path: &PathBuf, readonly: bool) -> Result<Self> {
if !path.exists() && !readonly {
let default_config = Self::default();
debug!("Creating default config at {:?}", default_config);
let s = toml::to_string_pretty(&default_config)
.context("Failed to serialize the default config")?;
fs::write(path, s).context("Failed to write the default config")?;
}
let s: String = fs::read_to_string(path)
.with_context(|| format!("Failed to read the config {:?}", path))?;
let mut cfg: Self = toml::from_str(&s).with_context(|| {
"Configuration is invalid. Please refer to the configuration specification."
})?;
cfg.config_path = path.clone();
cfg.readonly = readonly;
Ok(cfg)
}
pub fn get_config_dir(user_dir: bool) -> Result<PathBuf> {
let dir = if user_dir {
let mut dir = dirs::config_dir().context("Can't get config_dir")?;
dir.push("cloudpub");
dir
} else if cfg!(target_family = "unix") {
PathBuf::from("./etc/cloudpub")
} else {
PathBuf::from("C:\\Windows\\system32\\config\\systemprofile\\AppData\\Local\\cloudpub")
};
if !dir.exists() {
create_dir_all(&dir).context("Can't create config dir")?;
}
debug!("Config dir: {:?}", dir);
Ok(dir)
}
pub fn save(&self) -> Result<()> {
if self.readonly {
debug!("Skipping saving the config in readonly mode");
} else {
let s = toml::to_string_pretty(self).context("Failed to serialize the config")?;
let mut f = File::create(&self.config_path)?;
f.write_all(s.as_bytes())?;
f.sync_all()?;
}
Ok(())
}
pub fn load(cfg_name: &str, user_dir: bool, readonly: bool) -> Result<Self> {
let mut config_path = Self::get_config_dir(user_dir)?;
config_path.push(cfg_name);
Self::from_file(&config_path, readonly)
}
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
let option = ConfigOption::from_str(key)?;
self.set_option(option, value)
}
pub fn get(&self, key: &str) -> Result<String> {
let option = ConfigOption::from_str(key)?;
self.get_option(option)
}
pub fn get_all_options(&self) -> Vec<(String, String)> {
self.get_all_config_options()
}
pub fn validate(&self) -> Result<()> {
if self.token.is_none() {
bail!("{}", crate::t!("error-auth-missing"));
}
TransportConfig::validate(&self.transport, false)?;
Ok(())
}
#[cfg(not(target_os = "android"))]
pub fn get_hwid(&self) -> String {
self.hwid
.as_ref()
.unwrap_or(
&IdBuilder::new(Encryption::SHA256)
.add_component(HWIDComponent::OSName)
.add_component(HWIDComponent::SystemID)
.add_component(HWIDComponent::MachineName)
.add_component(HWIDComponent::CPUID)
.build("cloudpub")
.unwrap_or(self.agent_id.clone()),
)
.to_string()
}
#[cfg(target_os = "android")]
pub fn get_hwid(&self) -> String {
self.hwid.as_ref().unwrap_or(&self.agent_id).to_string()
}
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
agent_id: Uuid::new_v4().to_string(),
hwid: None,
config_path: PathBuf::new(),
server: format!("https://{}:{}", DOMAIN.as_str(), *PORT)
.parse()
.unwrap(),
token: None,
heartbeat_timeout: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
one_c_home: None,
one_c_platform: None,
one_c_publish_dir: None,
minecraft_server: None,
minecraft_java_opts: None,
minimize_to_tray_on_close: Some(true),
minimize_to_tray_on_start: Some(false),
signed_out: None,
transport: TransportConfig::default(),
readonly: false,
}
}
}
#[cfg(test)]
mod tests {
use super::{platform_for_arch, Platform, ENV_CONFIG};
use regex::Regex;
fn plugin_bundles() -> Vec<&'static str> {
let config = include_str!("config.rs");
let literal = Regex::new(r#"(?:httpd|redist):\s*"([^"]+)"#).unwrap();
let bundles: Vec<&str> = literal
.captures_iter(config)
.filter_map(|c| c.get(1))
.map(|m| m.as_str())
.filter(|name| !name.is_empty())
.collect();
assert!(
bundles.len() >= 6,
"expected the platform table to name at least 6 bundles, found {bundles:?}"
);
bundles
}
#[test]
fn each_linux_arch_maps_to_its_own_platform() {
assert_eq!(platform_for_arch("x86_64"), Platform::X64);
assert_eq!(platform_for_arch("aarch64"), Platform::Aarch64);
assert_eq!(platform_for_arch("arm"), Platform::Arm);
}
#[test]
fn an_unknown_arch_falls_back_to_x64() {
assert_eq!(platform_for_arch("riscv64"), Platform::X64);
}
#[cfg(target_os = "linux")]
#[test]
fn every_linux_arch_has_a_bundle_and_a_cache_dir_of_its_own() {
let x64 = ENV_CONFIG.get(&Platform::X64).unwrap();
let aarch64 = ENV_CONFIG.get(&Platform::Aarch64).unwrap();
let arm = ENV_CONFIG.get(&Platform::Arm).unwrap();
assert_eq!(aarch64.httpd, "httpd-2.4.68-aarch64-linux.zip");
assert_eq!(arm.httpd, "httpd-2.4.68-arm-linux.zip");
assert_eq!(x64.httpd_dir, "httpd-x64");
assert_eq!(aarch64.httpd_dir, "httpd-aarch64");
assert_eq!(arm.httpd_dir, "httpd-arm");
let dirs = [&x64.httpd_dir, &aarch64.httpd_dir, &arm.httpd_dir];
assert_eq!(
dirs.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"two architectures share a cache directory"
);
}
#[test]
fn every_plugin_bundle_is_shipped_in_the_server_image() {
let dockerfile = include_str!("../../server/Dockerfile");
let wanted = plugin_bundles();
let missing: Vec<&&str> = wanted
.iter()
.filter(|name| !dockerfile.contains(**name))
.collect();
assert!(
missing.is_empty(),
"server/Dockerfile does not fetch {missing:?}; an agent on that \
platform cannot install the httpd plugin from an on-prem server"
);
}
#[test]
fn the_plugin_bundles_come_from_the_package_registry() {
let dockerfile = include_str!("../../server/Dockerfile");
let gitlab = include_str!("../../bin/gitlab");
let missing: Vec<&str> = plugin_bundles()
.into_iter()
.filter(|name| !dockerfile.contains(&format!("build-deps/{name}")))
.collect();
assert!(
missing.is_empty(),
"server/Dockerfile does not COPY {missing:?} out of build-deps; \
the build depends on the public site being reachable"
);
assert!(
!dockerfile.contains("cloudpub.ru/download/"),
"server/Dockerfile still fetches from cloudpub.ru during the build"
);
let unstaged: Vec<&str> = plugin_bundles()
.into_iter()
.filter(|name| !gitlab.contains(&format!("\"{name}\"")))
.collect();
assert!(
unstaged.is_empty(),
"bin/gitlab BUILD_DEPS does not fetch {unstaged:?}, so the COPY \
has nothing to copy"
);
}
}