mirror of
				https://git.proxmox.com/git/proxmox
				synced 2025-11-03 23:52:46 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			226 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			226 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
use std::sync::LazyLock;
 | 
						|
 | 
						|
use anyhow::{bail, format_err, Error};
 | 
						|
 | 
						|
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,
 | 
						|
};
 | 
						|
 | 
						|
static ATTR_RE: LazyLock<Regex> =
 | 
						|
    LazyLock::new(|| 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<C: HttpClient<String, String>>(
 | 
						|
    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<SubscriptionInfo, Error> {
 | 
						|
    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" => value.clone_into(&mut md5hash),
 | 
						|
            _ => (),
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    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#"
 | 
						|
<status>Active</status>
 | 
						|
<companyname>Proxmox</companyname>
 | 
						|
<serviceid>41108</serviceid>
 | 
						|
<productid>71</productid>
 | 
						|
<productname>Proxmox Backup Server Test Subscription -1 year</productname>
 | 
						|
<regdate>2020-09-19 00:00:00</regdate>
 | 
						|
<nextduedate>2021-09-19</nextduedate>
 | 
						|
<billingcycle>Annually</billingcycle>
 | 
						|
<validdomain>proxmox.com,www.proxmox.com</validdomain>
 | 
						|
<validdirectory>830000000123456789ABCDEF00000042</validdirectory>
 | 
						|
<customfields>Notes=Test Key!</customfields>
 | 
						|
<addons></addons>
 | 
						|
<md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
 | 
						|
"#;
 | 
						|
    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#"
 | 
						|
<status>Suspended</status>
 | 
						|
<companyname>Test 12345</companyname>
 | 
						|
<serviceid>65977</serviceid>
 | 
						|
<productid>11</productid>
 | 
						|
<productname>Proxmox VE Test Subscription -1 year</productname>
 | 
						|
<regdate>2022-09-20 00:00:00</regdate>
 | 
						|
<nextduedate>2022-09-20</nextduedate>
 | 
						|
<billingcycle>Annually</billingcycle>
 | 
						|
<validdomain>proxmox.com,www.proxmox.com</validdomain>
 | 
						|
<validdirectory>830000000123456789ABCDEF00000042</validdirectory>
 | 
						|
<customfields>Notes=Test Key!</customfields>
 | 
						|
<addons></addons>
 | 
						|
<md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
 | 
						|
"#;
 | 
						|
 | 
						|
    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<C: HttpClient<String, String>>(
 | 
						|
    key: String,
 | 
						|
    server_id: String,
 | 
						|
    product_url: String,
 | 
						|
    http_client: C,
 | 
						|
) -> Result<SubscriptionInfo, Error> {
 | 
						|
    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))
 | 
						|
}
 |