-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};
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,
+ },
},
},
- returns: {
- description: "Subscription status.",
+ 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: {
- 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.",
+ node: {
+ schema: NODE_SCHEMA,
},
},
},
+ returns: {
+ description: "Subscription status.",
+ type: SubscriptionInfo,
+ },
access: {
permission: &Permission::Anybody,
},
fn get_subscription(
_param: Value,
rpcenv: &mut dyn RpcEnvironment,
-) -> Result<Value, Error> {
+) -> Result<SubscriptionInfo, Error> {
+ 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);
--- /dev/null
+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<String>,
+ /// timestamp of the last check done
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub checktime: Option<i64>,
+ /// the subscription key, if set and permitted to access
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub key: Option<String>,
+ /// a more human readable status message
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub message: Option<String>,
+ /// human readable productname of the set subscription
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub productname: Option<String>,
+ /// register date of the set subscription
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub regdate: Option<String>,
+ /// next due date of the set subscription
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub nextduedate: Option<String>,
+ /// URL to the web shop
+ #[serde(skip_serializing_if="Option::is_none")]
+ pub url: Option<String>,
+}
+
+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<SubscriptionInfo, Error> {
+ 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#"
+<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)?;
+
+ 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<SubscriptionInfo, Error> {
+
+ 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<Option<SubscriptionInfo>, 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::<String>();
+ 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<String>, password: Option<String>) -> 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(())
+}