1 use anyhow
::{bail, format_err, Error}
;
2 use lazy_static
::lazy_static
;
4 use serde
::{Deserialize, Serialize}
;
7 use proxmox_schema
::api
;
9 use proxmox_http
::client
::SimpleHttp
;
10 use proxmox_http
::uri
::json_object_to_query
;
11 use proxmox_sys
::fs
::{replace_file, CreateOptions}
;
13 use crate::config
::node
;
14 use crate::tools
::{self, pbs_simple_http}
;
16 /// How long the local key is valid for in between remote checks
17 pub const MAX_LOCAL_KEY_AGE
: i64 = 15 * 24 * 3600;
18 const MAX_KEY_CHECK_FAILURE_AGE
: i64 = 5 * 24 * 3600;
20 const SHARED_KEY_DATA
: &str = "kjfdlskfhiuewhfk947368";
21 const SUBSCRIPTION_FN
: &str = "/etc/proxmox-backup/subscription";
22 const APT_AUTH_FN
: &str = "/etc/apt/auth.conf.d/pbs.conf";
25 #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
26 #[serde(rename_all = "lowercase")]
27 /// Subscription status
28 pub enum SubscriptionStatus
{
30 /// newly set subscription, not yet checked
32 /// no subscription set
34 /// subscription set and active
36 /// subscription set but invalid for this server
39 impl Default
for SubscriptionStatus
{
40 fn default() -> Self {
41 SubscriptionStatus
::NOTFOUND
44 impl std
::fmt
::Display
for SubscriptionStatus
{
45 fn fmt(&self, f
: &mut std
::fmt
::Formatter
<'_
>) -> std
::fmt
::Result
{
47 SubscriptionStatus
::NEW
=> write
!(f
, "New"),
48 SubscriptionStatus
::NOTFOUND
=> write
!(f
, "NotFound"),
49 SubscriptionStatus
::ACTIVE
=> write
!(f
, "Active"),
50 SubscriptionStatus
::INVALID
=> write
!(f
, "Invalid"),
58 type: SubscriptionStatus
,
62 #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
63 #[serde(rename_all = "kebab-case")]
64 /// Proxmox subscription information
65 pub struct SubscriptionInfo
{
66 /// Subscription status from the last check
67 pub status
: SubscriptionStatus
,
68 /// the server ID, if permitted to access
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub serverid
: Option
<String
>,
71 /// timestamp of the last check done
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub checktime
: Option
<i64>,
74 /// the subscription key, if set and permitted to access
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub key
: Option
<String
>,
77 /// a more human readable status message
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub message
: Option
<String
>,
80 /// human readable productname of the set subscription
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub productname
: Option
<String
>,
83 /// register date of the set subscription
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub regdate
: Option
<String
>,
86 /// next due date of the set subscription
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub nextduedate
: Option
<String
>,
89 /// URL to the web shop
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub url
: Option
<String
>,
94 async
fn register_subscription(
98 ) -> Result
<(String
, String
), Error
> {
99 // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple
100 let rand
= hex
::encode(&proxmox_sys
::linux
::random_data(16)?
);
101 let challenge
= format
!("{}{}", checktime
, rand
);
106 "domain": "www.proxmox.com",
108 "check_token": challenge
,
111 let proxy_config
= if let Ok((node_config
, _digest
)) = node
::config() {
112 node_config
.http_proxy()
117 let client
= pbs_simple_http(proxy_config
);
119 let uri
= "https://shop.proxmox.com/modules/servers/licensing/verify.php";
120 let query
= json_object_to_query(params
)?
;
121 let response
= client
122 .post(uri
, Some(query
), Some("application/x-www-form-urlencoded"))
124 let body
= SimpleHttp
::response_body_string(response
).await?
;
126 Ok((body
, challenge
))
129 fn parse_status(value
: &str) -> SubscriptionStatus
{
130 match value
.to_lowercase().as_str() {
131 "active" => SubscriptionStatus
::ACTIVE
,
132 "new" => SubscriptionStatus
::NEW
,
133 "notfound" => SubscriptionStatus
::NOTFOUND
,
134 "invalid" => SubscriptionStatus
::INVALID
,
135 _
=> SubscriptionStatus
::INVALID
,
139 fn parse_register_response(
145 ) -> Result
<SubscriptionInfo
, Error
> {
147 static ref ATTR_RE
: Regex
= Regex
::new(r
"<([^>]+)>([^<]+)</[^>]+>").unwrap();
150 let mut info
= SubscriptionInfo
{
152 status
: SubscriptionStatus
::NOTFOUND
,
153 checktime
: Some(checktime
),
154 url
: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
157 let mut md5hash
= String
::new();
158 let is_server_id
= |id
: &&str| *id
== server_id
;
160 for caps
in ATTR_RE
.captures_iter(body
) {
161 let (key
, value
) = (&caps
[1], &caps
[2]);
163 "status" => info
.status
= parse_status(value
),
164 "productname" => info
.productname
= Some(value
.into()),
165 "regdate" => info
.regdate
= Some(value
.into()),
166 "nextduedate" => info
.nextduedate
= Some(value
.into()),
167 "message" if value
== "Directory Invalid" => {
168 info
.message
= Some("Invalid Server ID".into())
170 "message" => info
.message
= Some(value
.into()),
171 "validdirectory" => {
172 if value
.split('
,'
).find(is_server_id
) == None
{
173 bail
!("Server ID does not match");
175 info
.serverid
= Some(server_id
.to_owned());
177 "md5hash" => md5hash
= value
.to_owned(),
182 if let SubscriptionStatus
::ACTIVE
= info
.status
{
183 let response_raw
= format
!("{}{}", SHARED_KEY_DATA
, challenge
);
184 let expected
= hex
::encode(&tools
::md5sum(response_raw
.as_bytes())?
);
185 if expected
!= md5hash
{
187 "Subscription API challenge failed, expected {} != got {}",
197 fn test_parse_register_response() -> Result
<(), Error
> {
199 <status>Active</status>
200 <companyname>Proxmox</companyname>
201 <serviceid>41108</serviceid>
202 <productid>71</productid>
203 <productname>Proxmox Backup Server Test Subscription -1 year</productname>
204 <regdate>2020-09-19 00:00:00</regdate>
205 <nextduedate>2021-09-19</nextduedate>
206 <billingcycle>Annually</billingcycle>
207 <validdomain>proxmox.com,www.proxmox.com</validdomain>
208 <validdirectory>830000000123456789ABCDEF00000042</validdirectory>
209 <customfields>Notes=Test Key!</customfields>
211 <md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
213 let key
= "pbst-123456789a".to_string();
214 let server_id
= "830000000123456789ABCDEF00000042".to_string();
215 let checktime
= 1600000000;
216 let salt
= "cf44486bddb6ad0145732642c45b2957";
218 let info
= parse_register_response(
221 server_id
.to_owned(),
230 serverid
: Some(server_id
),
231 status
: SubscriptionStatus
::ACTIVE
,
232 checktime
: Some(checktime
),
233 url
: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
235 nextduedate
: Some("2021-09-19".into()),
236 regdate
: Some("2020-09-19 00:00:00".into()),
237 productname
: Some("Proxmox Backup Server Test Subscription -1 year".into()),
243 /// queries the up to date subscription status and parses the response
244 pub fn check_subscription(key
: String
, server_id
: String
) -> Result
<SubscriptionInfo
, Error
> {
245 let now
= proxmox_time
::epoch_i64();
247 let (response
, challenge
) =
248 proxmox_async
::runtime
::block_on(register_subscription(&key
, &server_id
, now
))
249 .map_err(|err
| format_err
!("Error checking subscription: {}", err
))?
;
251 parse_register_response(&response
, key
, server_id
, now
, &challenge
)
252 .map_err(|err
| format_err
!("Error parsing subscription check response: {}", err
))
255 /// reads in subscription information and does a basic integrity verification
256 pub fn read_subscription() -> Result
<Option
<SubscriptionInfo
>, Error
> {
257 let cfg
= proxmox_sys
::fs
::file_read_optional_string(&SUBSCRIPTION_FN
)?
;
258 let cfg
= if let Some(cfg
) = cfg
{
264 let mut cfg
= cfg
.lines();
266 // first line is key in plain
267 let _key
= if let Some(key
) = cfg
.next() {
272 // second line is checksum of encoded data
273 let checksum
= if let Some(csum
) = cfg
.next() {
279 let encoded
: String
= cfg
.collect
::<String
>();
280 let decoded
= base64
::decode(&encoded
)?
;
281 let decoded
= std
::str::from_utf8(&decoded
)?
;
283 let info
: SubscriptionInfo
= serde_json
::from_str(decoded
)?
;
285 let new_checksum
= format
!(
287 info
.checktime
.unwrap_or(0),
291 let new_checksum
= base64
::encode(tools
::md5sum(new_checksum
.as_bytes())?
);
293 if checksum
!= new_checksum
{
294 return Ok(Some(SubscriptionInfo
{
295 status
: SubscriptionStatus
::INVALID
,
296 message
: Some("checksum mismatch".to_string()),
301 let age
= proxmox_time
::epoch_i64() - info
.checktime
.unwrap_or(0);
303 // allow some delta for DST changes or time syncs, 1.5h
304 return Ok(Some(SubscriptionInfo
{
305 status
: SubscriptionStatus
::INVALID
,
306 message
: Some("last check date too far in the future".to_string()),
309 } else if age
> MAX_LOCAL_KEY_AGE
+ MAX_KEY_CHECK_FAILURE_AGE
{
310 if let SubscriptionStatus
::ACTIVE
= info
.status
{
311 return Ok(Some(SubscriptionInfo
{
312 status
: SubscriptionStatus
::INVALID
,
313 message
: Some("subscription information too old".to_string()),
322 /// writes out subscription status
323 pub fn write_subscription(info
: SubscriptionInfo
) -> Result
<(), Error
> {
324 let key
= info
.key
.to_owned();
325 let server_id
= info
.serverid
.to_owned();
327 let raw
= if info
.key
== None
|| info
.checktime
== None
{
329 } else if let SubscriptionStatus
::NEW
= info
.status
{
330 format
!("{}\n", info
.key
.unwrap())
332 let encoded
= base64
::encode(serde_json
::to_string(&info
)?
);
335 info
.checktime
.unwrap_or(0),
339 let csum
= base64
::encode(tools
::md5sum(csum
.as_bytes())?
);
340 format
!("{}\n{}\n{}\n", info
.key
.unwrap(), csum
, encoded
)
343 let backup_user
= pbs_config
::backup_user()?
;
344 let mode
= nix
::sys
::stat
::Mode
::from_bits_truncate(0o0640);
345 let file_opts
= CreateOptions
::new()
347 .owner(nix
::unistd
::ROOT
)
348 .group(backup_user
.gid
);
350 let subscription_file
= std
::path
::Path
::new(SUBSCRIPTION_FN
);
351 replace_file(subscription_file
, raw
.as_bytes(), file_opts
, true)?
;
353 update_apt_auth(key
, server_id
)?
;
358 /// deletes subscription from server
359 pub fn delete_subscription() -> Result
<(), Error
> {
360 let subscription_file
= std
::path
::Path
::new(SUBSCRIPTION_FN
);
361 nix
::unistd
::unlink(subscription_file
)?
;
362 update_apt_auth(None
, None
)?
;
366 /// updates apt authentication for repo access
367 pub fn update_apt_auth(key
: Option
<String
>, password
: Option
<String
>) -> Result
<(), Error
> {
368 let auth_conf
= std
::path
::Path
::new(APT_AUTH_FN
);
369 match (key
, password
) {
370 (Some(key
), Some(password
)) => {
372 "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n",
375 let mode
= nix
::sys
::stat
::Mode
::from_bits_truncate(0o0640);
376 let file_opts
= CreateOptions
::new().perm(mode
).owner(nix
::unistd
::ROOT
);
378 // we use a namespaced .conf file, so just overwrite..
379 replace_file(auth_conf
, conf
.as_bytes(), file_opts
, true)
380 .map_err(|e
| format_err
!("Error saving apt auth config - {}", e
))?
;
382 _
=> match nix
::unistd
::unlink(auth_conf
) {
384 Err(nix
::errno
::Errno
::ENOENT
) => Ok(()), // ignore not existing
385 Err(err
) => Err(err
),
387 .map_err(|e
| format_err
!("Error clearing apt auth config - {}", e
))?
,