implement subscription handling and api
mostly modelled after PVE Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
This commit is contained in:
parent
2e201e7da6
commit
7b22fb257f
|
@ -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<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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<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(())
|
||||
}
|
Loading…
Reference in New Issue