From 7b22fb257f8fdd23a01ca660a86c1c3afb210b26 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 27 Oct 2020 12:25:59 +0100 Subject: [PATCH] implement subscription handling and api mostly modelled after PVE Signed-off-by: Thomas Lamprecht --- src/api2/node/subscription.rs | 175 ++++++++++++++---- src/tools.rs | 1 + src/tools/subscription.rs | 325 ++++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 src/tools/subscription.rs diff --git a/src/api2/node/subscription.rs b/src/api2/node/subscription.rs index f8b14187..55e0b5d6 100644 --- a/src/api2/node/subscription.rs +++ b/src/api2/node/subscription.rs @@ -1,13 +1,68 @@ -use anyhow::{Error}; -use serde_json::{json, Value}; +use anyhow::{Error, format_err, bail}; +use serde_json::Value; use proxmox::api::{api, Router, RpcEnvironment, Permission}; use crate::tools; -use crate::config::acl::PRIV_SYS_AUDIT; +use crate::tools::subscription::{self, SubscriptionStatus, SubscriptionInfo}; +use crate::config::acl::{PRIV_SYS_AUDIT,PRIV_SYS_MODIFY}; use crate::config::cached_user_info::CachedUserInfo; use crate::api2::types::{NODE_SCHEMA, Userid}; +#[api( + input: { + properties: { + node: { + schema: NODE_SCHEMA, + }, + force: { + description: "Always connect to server, even if information in cache is up to date.", + type: bool, + optional: true, + default: false, + }, + }, + }, + protected: true, + access: { + permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false), + }, +)] +/// Check and update subscription status. +fn check_subscription( + force: bool, +) -> Result<(), Error> { + + let info = match subscription::read_subscription() { + Err(err) => bail!("could not read subscription status: {}", err), + Ok(Some(info)) => info, + Ok(None) => return Ok(()), + }; + + let server_id = tools::get_hardware_address()?; + let key = if let Some(key) = info.key { + // always update apt auth if we have a key to ensure user can access enterprise repo + subscription::update_apt_auth(Some(key.to_owned()), Some(server_id.to_owned()))?; + key + } else { + String::new() + }; + + if !force && info.status == SubscriptionStatus::ACTIVE { + let age = proxmox::tools::time::epoch_i64() - info.checktime.unwrap_or(i64::MAX); + if age < subscription::MAX_LOCAL_KEY_AGE { + return Ok(()); + } + } + + let info = subscription::check_subscription(key, server_id)?; + + subscription::write_subscription(info) + .map_err(|e| format_err!("Error writing updated subscription status - {}", e))?; + + Ok(()) +} + #[api( input: { properties: { @@ -18,24 +73,7 @@ use crate::api2::types::{NODE_SCHEMA, Userid}; }, returns: { description: "Subscription status.", - properties: { - status: { - type: String, - description: "'NotFound', 'active' or 'inactive'." - }, - message: { - type: String, - description: "Human readable problem description.", - }, - serverid: { - type: String, - description: "The unique server ID, if permitted to access.", - }, - url: { - type: String, - description: "URL to Web Shop.", - }, - }, + type: SubscriptionInfo, }, access: { permission: &Permission::Anybody, @@ -45,24 +83,95 @@ use crate::api2::types::{NODE_SCHEMA, Userid}; fn get_subscription( _param: Value, rpcenv: &mut dyn RpcEnvironment, -) -> Result { +) -> Result { + let url = "https://www.proxmox.com/en/proxmox-backup-server/pricing"; + + let info = match subscription::read_subscription() { + Err(err) => bail!("could not read subscription status: {}", err), + Ok(Some(info)) => info, + Ok(None) => SubscriptionInfo { + status: SubscriptionStatus::NOTFOUND, + message: Some("There is no subscription key".into()), + serverid: Some(tools::get_hardware_address()?), + url: Some(url.into()), + ..Default::default() + }, + }; + let userid: Userid = rpcenv.get_user().unwrap().parse()?; let user_info = CachedUserInfo::new()?; let user_privs = user_info.lookup_privs(&userid, &[]); - let server_id = if (user_privs & PRIV_SYS_AUDIT) != 0 { - tools::get_hardware_address()? - } else { - "hidden".to_string() + + if (user_privs & PRIV_SYS_AUDIT) == 0 { + // not enough privileges for full state + return Ok(SubscriptionInfo { + status: info.status, + message: info.message, + url: info.url, + ..Default::default() + }); }; - let url = "https://www.proxmox.com/en/proxmox-backup-server/pricing"; - Ok(json!({ - "status": "NotFound", - "message": "There is no subscription key", - "serverid": server_id, - "url": url, - })) + Ok(info) +} + +#[api( + input: { + properties: { + node: { + schema: NODE_SCHEMA, + }, + key: { + description: "Proxmox Backup Server subscription key", + type: String, + max_length: 32, + }, + }, + }, + protected: true, + access: { + permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false), + }, +)] +/// Set a subscription key and check it. +fn set_subscription( + key: String, +) -> Result<(), Error> { + + let server_id = tools::get_hardware_address()?; + + let info = subscription::check_subscription(key, server_id.to_owned())?; + + subscription::write_subscription(info) + .map_err(|e| format_err!("Error writing subscription status - {}", e))?; + + Ok(()) +} + +#[api( + input: { + properties: { + node: { + schema: NODE_SCHEMA, + }, + }, + }, + protected: true, + access: { + permission: &Permission::Privilege(&["system"], PRIV_SYS_MODIFY, false), + }, +)] +/// Delete subscription info. +fn delete_subscription() -> Result<(), Error> { + + subscription::delete_subscription() + .map_err(|err| format_err!("Deleting subscription failed: {}", err))?; + + Ok(()) } pub const ROUTER: Router = Router::new() + .post(&API_METHOD_CHECK_SUBSCRIPTION) + .put(&API_METHOD_SET_SUBSCRIPTION) + .delete(&API_METHOD_DELETE_SUBSCRIPTION) .get(&API_METHOD_GET_SUBSCRIPTION); diff --git a/src/tools.rs b/src/tools.rs index fa52759f..da79aa75 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -36,6 +36,7 @@ pub mod logrotate; pub mod loopdev; pub mod fuse_loop; pub mod socket; +pub mod subscription; pub mod zip; pub mod http; diff --git a/src/tools/subscription.rs b/src/tools/subscription.rs new file mode 100644 index 00000000..20fc063d --- /dev/null +++ b/src/tools/subscription.rs @@ -0,0 +1,325 @@ +use anyhow::{Error, format_err, bail}; +use lazy_static::lazy_static; +use serde_json::json; +use serde::{Deserialize, Serialize}; +use regex::Regex; + +use proxmox::api::api; + +use crate::tools; +use crate::tools::http; +use proxmox::tools::fs::{replace_file, CreateOptions}; + +/// How long the local key is valid for in between remote checks +pub const MAX_LOCAL_KEY_AGE: i64 = 15 * 24 * 3600; +const MAX_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600; + +const SHARED_KEY_DATA: &str = "kjfdlskfhiuewhfk947368"; +const SUBSCRIPTION_FN: &str = "/etc/proxmox-backup/subscription"; +const APT_AUTH_FN: &str = "/etc/apt/auth.conf.d/pbs.conf"; + +#[api()] +#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Subscription status +pub enum SubscriptionStatus { + // FIXME: remove? + /// newly set subscription, not yet checked + NEW, + /// no subscription set + NOTFOUND, + /// subscription set and active + ACTIVE, + /// subscription set but invalid for this server + INVALID, +} +impl Default for SubscriptionStatus { + fn default() -> Self { SubscriptionStatus::NOTFOUND } +} +impl std::fmt::Display for SubscriptionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SubscriptionStatus::NEW => write!(f, "New"), + SubscriptionStatus::NOTFOUND => write!(f, "NotFound"), + SubscriptionStatus::ACTIVE => write!(f, "Active"), + SubscriptionStatus::INVALID => write!(f, "Invalid"), + } + } +} + +#[api( + properties: { + status: { + type: SubscriptionStatus, + }, + }, +)] +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all="kebab-case")] +/// Proxmox subscription information +pub struct SubscriptionInfo { + /// Subscription status from the last check + pub status: SubscriptionStatus, + /// the server ID, if permitted to access + #[serde(skip_serializing_if="Option::is_none")] + pub serverid: Option, + /// timestamp of the last check done + #[serde(skip_serializing_if="Option::is_none")] + pub checktime: Option, + /// the subscription key, if set and permitted to access + #[serde(skip_serializing_if="Option::is_none")] + pub key: Option, + /// a more human readable status message + #[serde(skip_serializing_if="Option::is_none")] + pub message: Option, + /// human readable productname of the set subscription + #[serde(skip_serializing_if="Option::is_none")] + pub productname: Option, + /// register date of the set subscription + #[serde(skip_serializing_if="Option::is_none")] + pub regdate: Option, + /// next due date of the set subscription + #[serde(skip_serializing_if="Option::is_none")] + pub nextduedate: Option, + /// URL to the web shop + #[serde(skip_serializing_if="Option::is_none")] + pub url: Option, +} + +async fn register_subscription( + key: &String, + server_id: &String, + checktime: i64 +) -> Result<(String, String), Error> { + // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple + let rand = proxmox::tools::bin_to_hex(&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 uri = "https://shop.maurer-it.com/modules/servers/licensing/verify.php"; + let query = tools::json_object_to_query(params)?; + let response = http::post(uri, Some(query), Some("application/x-www-form-urlencoded")).await?; + let body = http::response_body_string(response).await?; + + 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, + _ => SubscriptionStatus::INVALID, + } +} + +fn parse_register_response( + body: &str, + key: String, + server_id: String, + checktime: i64, + challenge: &str, +) -> Result { + lazy_static! { + static ref ATTR_RE: Regex = Regex::new(r"<([^>]+)>([^<]+)]+>").unwrap(); + } + + let mut info = SubscriptionInfo { + key: Some(key), + status: SubscriptionStatus::NOTFOUND, + checktime: Some(checktime), + url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()), + ..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(",").find(is_server_id) == None { + 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 = proxmox::tools::bin_to_hex(&tools::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)?; + + assert_eq!(info, SubscriptionInfo { + key: Some(key), + serverid: Some(server_id), + 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()), + }); + Ok(()) +} + +/// querys the up to date subscription status and parses the response +pub fn check_subscription(key: String, server_id: String) -> Result { + + let now = proxmox::tools::time::epoch_i64(); + + let (response, challenge) = tools::runtime::block_on(register_subscription(&key, &server_id, now)) + .map_err(|err| format_err!("Error checking subscription: {}", err))?; + + parse_register_response(&response, key, server_id, now, &challenge) + .map_err(|err| format_err!("Error parsing subscription check response: {}", err)) +} + +/// reads in subscription information and does a basic integrity verification +pub fn read_subscription() -> Result, Error> { + + let cfg = proxmox::tools::fs::file_read_optional_string(&SUBSCRIPTION_FN)?; + let cfg = if let Some(cfg) = cfg { cfg } else { return Ok(None); }; + + let mut cfg = cfg.lines(); + + // first line is key in plain + let _key = if let Some(key) = cfg.next() { key } else { return Ok(None) }; + // second line is checksum of encoded data + let checksum = if let Some(csum) = cfg.next() { csum } else { return Ok(None) }; + + let encoded: String = cfg.collect::(); + let decoded = base64::decode(encoded.to_owned())?; + let decoded = std::str::from_utf8(&decoded)?; + + let info: SubscriptionInfo = serde_json::from_str(decoded)?; + + let new_checksum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA); + let new_checksum = base64::encode(tools::md5sum(new_checksum.as_bytes())?); + + if checksum != new_checksum { + bail!("stored checksum doesn't matches computed one '{}' != '{}'", checksum, new_checksum); + } + + let age = proxmox::tools::time::epoch_i64() - info.checktime.unwrap_or(0); + if age < -5400 { // allow some delta for DST changes or time syncs, 1.5h + bail!("Last check time to far in the future."); + } else if age > MAX_LOCAL_KEY_AGE + MAX_KEY_CHECK_FAILURE_AGE { + if let SubscriptionStatus::ACTIVE = info.status { + bail!("subscription information too old"); + } + } + + Ok(Some(info)) +} + +/// writes out subscription status +pub fn write_subscription(info: SubscriptionInfo) -> Result<(), Error> { + let key = info.key.to_owned(); + let server_id = info.serverid.to_owned(); + + let raw = if info.key == None || info.checktime == None { + String::new() + } else if let SubscriptionStatus::NEW = info.status { + format!("{}\n", info.key.unwrap()) + } else { + let encoded = base64::encode(serde_json::to_string(&info)?); + let csum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA); + let csum = base64::encode(tools::md5sum(csum.as_bytes())?); + format!("{}\n{}\n{}\n", info.key.unwrap(), csum, encoded) + }; + + let backup_user = crate::backup::backup_user()?; + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + let file_opts = CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT) + .group(backup_user.gid); + + let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); + replace_file(subscription_file, raw.as_bytes(), file_opts)?; + + update_apt_auth(key, server_id)?; + + Ok(()) +} + +/// deletes subscription from server +pub fn delete_subscription() -> Result<(), Error> { + let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); + nix::unistd::unlink(subscription_file)?; + update_apt_auth(None, None)?; + Ok(()) +} + +/// updates apt authenification for repo access +pub fn update_apt_auth(key: Option, password: Option) -> Result<(), Error> { + let auth_conf = std::path::Path::new(APT_AUTH_FN); + match (key, password) { + (Some(key), Some(password)) => { + let conf = format!( + "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}", + key, + password, + ); + let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); + let file_opts = CreateOptions::new() + .perm(mode) + .owner(nix::unistd::ROOT); + + // we use a namespaced .conf file, so just overwrite.. + replace_file(auth_conf, conf.as_bytes(), file_opts) + .map_err(|e| format_err!("Error saving apt auth config - {}", e))?; + } + _ => nix::unistd::unlink(auth_conf) + .map_err(|e| format_err!("Error clearing apt auth config - {}", e))?, + } + Ok(()) +}