use anyhow::{bail, format_err, Error}; use lazy_static::lazy_static; use regex::Regex; use serde_json::json; use proxmox_http::{uri::json_object_to_query, HttpClient}; use crate::{ subscription_info::{md5sum, SHARED_KEY_DATA}, SubscriptionInfo, SubscriptionStatus, }; lazy_static! { static ref ATTR_RE: Regex = Regex::new(r"<([^>]+)>([^<]+)]+>").unwrap(); } const SHOP_URI: &str = "https://shop.proxmox.com/modules/servers/licensing/verify.php"; /// (Re)-register a subscription key with the WHMCS server. fn register_subscription>( key: &str, server_id: &str, checktime: i64, client: C, ) -> Result<(String, String), Error> { // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple let rand = hex::encode(&proxmox_sys::linux::random_data(16)?); let challenge = format!("{}{}", checktime, rand); let params = json!({ "licensekey": key, "dir": server_id, "domain": "www.proxmox.com", "ip": "localhost", "check_token": challenge, }); let query = json_object_to_query(params)?; let response = client.post( SHOP_URI, Some(query), Some("application/x-www-form-urlencoded"), None, )?; let body = response.into_body(); Ok((body, challenge)) } fn parse_status(value: &str) -> SubscriptionStatus { match value.to_lowercase().as_str() { "active" => SubscriptionStatus::Active, "new" => SubscriptionStatus::New, "notfound" => SubscriptionStatus::NotFound, "invalid" => SubscriptionStatus::Invalid, "expired" => SubscriptionStatus::Expired, "suspended" => SubscriptionStatus::Suspended, _ => SubscriptionStatus::Invalid, } } fn parse_register_response( body: &str, key: String, server_id: String, checktime: i64, challenge: &str, product_url: String, ) -> Result { let mut info = SubscriptionInfo { key: Some(key), status: SubscriptionStatus::NotFound, checktime: Some(checktime), url: Some(product_url), ..Default::default() }; let mut md5hash = String::new(); let is_server_id = |id: &str| *id == server_id; for caps in ATTR_RE.captures_iter(body) { let (key, value) = (&caps[1], &caps[2]); match key { "status" => info.status = parse_status(value), "productname" => info.productname = Some(value.into()), "regdate" => info.regdate = Some(value.into()), "nextduedate" => info.nextduedate = Some(value.into()), "message" if value == "Directory Invalid" => { info.message = Some("Invalid Server ID".into()) } "message" => info.message = Some(value.into()), "validdirectory" => { if !value.split(',').any(is_server_id) { bail!("Server ID does not match"); } info.serverid = Some(server_id.to_owned()); } "md5hash" => md5hash = value.to_owned(), _ => (), } } if let SubscriptionStatus::Active = info.status { let response_raw = format!("{}{}", SHARED_KEY_DATA, challenge); let expected = hex::encode(md5sum(response_raw.as_bytes())?); if expected != md5hash { bail!( "Subscription API challenge failed, expected {} != got {}", expected, md5hash ); } } Ok(info) } #[test] fn test_parse_register_response() -> Result<(), Error> { let response = r#" Active Proxmox 41108 71 Proxmox Backup Server Test Subscription -1 year 2020-09-19 00:00:00 2021-09-19 Annually proxmox.com,www.proxmox.com 830000000123456789ABCDEF00000042 Notes=Test Key! 969f4df84fe157ee4f5a2f71950ad154 "#; let key = "pbst-123456789a".to_string(); let server_id = "830000000123456789ABCDEF00000042".to_string(); let checktime = 1600000000; let salt = "cf44486bddb6ad0145732642c45b2957"; let info = parse_register_response( response, key.to_owned(), server_id.to_owned(), checktime, salt, "https://www.proxmox.com/en/proxmox-backup-server/pricing".to_string(), )?; assert_eq!( info, SubscriptionInfo { key: Some(key), serverid: Some(server_id.clone()), status: SubscriptionStatus::Active, checktime: Some(checktime), url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()), message: None, nextduedate: Some("2021-09-19".into()), regdate: Some("2020-09-19 00:00:00".into()), productname: Some("Proxmox Backup Server Test Subscription -1 year".into()), signature: None, } ); let response = r#" Suspended Test 12345 65977 11 Proxmox VE Test Subscription -1 year 2022-09-20 00:00:00 2022-09-20 Annually proxmox.com,www.proxmox.com 830000000123456789ABCDEF00000042 Notes=Test Key! 969f4df84fe157ee4f5a2f71950ad154 "#; let key = "pvet-123456789a".to_string(); let info = parse_register_response( response, key.to_owned(), server_id.to_owned(), checktime, salt, "https://www.proxmox.com/en/proxmox-ve/pricing".to_string(), )?; assert_eq!( info, SubscriptionInfo { key: Some(key), serverid: Some(server_id), status: SubscriptionStatus::Suspended, checktime: Some(checktime), url: Some("https://www.proxmox.com/en/proxmox-ve/pricing".into()), message: None, nextduedate: Some("2022-09-20".into()), regdate: Some("2022-09-20 00:00:00".into()), productname: Some("Proxmox VE Test Subscription -1 year".into()), signature: None, } ); Ok(()) } /// Queries the WHMCS server to register/update the subscription key information, parsing the /// response into a [SubscriptionInfo]. pub fn check_subscription>( key: String, server_id: String, product_url: String, http_client: C, ) -> Result { let now = proxmox_time::epoch_i64(); let (response, challenge) = register_subscription(&key, &server_id, now, http_client) .map_err(|err| format_err!("Error checking subscription: {}", err))?; parse_register_response(&response, key, server_id, now, &challenge, product_url) .map_err(|err| format_err!("Error parsing subscription check response: {}", err)) }