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_sys
::fs
::{replace_file, CreateOptions}
;
12 use pbs_tools
::json
::json_object_to_query
;
14 use crate::config
::node
;
15 use crate::tools
::{self, pbs_simple_http}
;
17 /// How long the local key is valid for in between remote checks
18 pub const MAX_LOCAL_KEY_AGE
: i64 = 15 * 24 * 3600;
19 const MAX_KEY_CHECK_FAILURE_AGE
: i64 = 5 * 24 * 3600;
21 const SHARED_KEY_DATA
: &str = "kjfdlskfhiuewhfk947368";
22 const SUBSCRIPTION_FN
: &str = "/etc/proxmox-backup/subscription";
23 const APT_AUTH_FN
: &str = "/etc/apt/auth.conf.d/pbs.conf";
26 #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
27 #[serde(rename_all = "lowercase")]
28 /// Subscription status
29 pub enum SubscriptionStatus
{
31 /// newly set subscription, not yet checked
33 /// no subscription set
35 /// subscription set and active
37 /// subscription set but invalid for this server
40 impl Default
for SubscriptionStatus
{
41 fn default() -> Self {
42 SubscriptionStatus
::NOTFOUND
45 impl std
::fmt
::Display
for SubscriptionStatus
{
46 fn fmt(&self, f
: &mut std
::fmt
::Formatter
<'_
>) -> std
::fmt
::Result
{
48 SubscriptionStatus
::NEW
=> write
!(f
, "New"),
49 SubscriptionStatus
::NOTFOUND
=> write
!(f
, "NotFound"),
50 SubscriptionStatus
::ACTIVE
=> write
!(f
, "Active"),
51 SubscriptionStatus
::INVALID
=> write
!(f
, "Invalid"),
59 type: SubscriptionStatus
,
63 #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
64 #[serde(rename_all = "kebab-case")]
65 /// Proxmox subscription information
66 pub struct SubscriptionInfo
{
67 /// Subscription status from the last check
68 pub status
: SubscriptionStatus
,
69 /// the server ID, if permitted to access
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub serverid
: Option
<String
>,
72 /// timestamp of the last check done
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub checktime
: Option
<i64>,
75 /// the subscription key, if set and permitted to access
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub key
: Option
<String
>,
78 /// a more human readable status message
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub message
: Option
<String
>,
81 /// human readable productname of the set subscription
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub productname
: Option
<String
>,
84 /// register date of the set subscription
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub regdate
: Option
<String
>,
87 /// next due date of the set subscription
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub nextduedate
: Option
<String
>,
90 /// URL to the web shop
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub url
: Option
<String
>,
95 async
fn register_subscription(
99 ) -> Result
<(String
, String
), Error
> {
100 // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple
101 let rand
= hex
::encode(&proxmox_sys
::linux
::random_data(16)?
);
102 let challenge
= format
!("{}{}", checktime
, rand
);
107 "domain": "www.proxmox.com",
109 "check_token": challenge
,
112 let proxy_config
= if let Ok((node_config
, _digest
)) = node
::config() {
113 node_config
.http_proxy()
118 let client
= pbs_simple_http(proxy_config
);
120 let uri
= "https://shop.proxmox.com/modules/servers/licensing/verify.php";
121 let query
= json_object_to_query(params
)?
;
122 let response
= client
123 .post(uri
, Some(query
), Some("application/x-www-form-urlencoded"))
125 let body
= SimpleHttp
::response_body_string(response
).await?
;
127 Ok((body
, challenge
))
130 fn parse_status(value
: &str) -> SubscriptionStatus
{
131 match value
.to_lowercase().as_str() {
132 "active" => SubscriptionStatus
::ACTIVE
,
133 "new" => SubscriptionStatus
::NEW
,
134 "notfound" => SubscriptionStatus
::NOTFOUND
,
135 "invalid" => SubscriptionStatus
::INVALID
,
136 _
=> SubscriptionStatus
::INVALID
,
140 fn parse_register_response(
146 ) -> Result
<SubscriptionInfo
, Error
> {
148 static ref ATTR_RE
: Regex
= Regex
::new(r
"<([^>]+)>([^<]+)</[^>]+>").unwrap();
151 let mut info
= SubscriptionInfo
{
153 status
: SubscriptionStatus
::NOTFOUND
,
154 checktime
: Some(checktime
),
155 url
: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
158 let mut md5hash
= String
::new();
159 let is_server_id
= |id
: &&str| *id
== server_id
;
161 for caps
in ATTR_RE
.captures_iter(body
) {
162 let (key
, value
) = (&caps
[1], &caps
[2]);
164 "status" => info
.status
= parse_status(value
),
165 "productname" => info
.productname
= Some(value
.into()),
166 "regdate" => info
.regdate
= Some(value
.into()),
167 "nextduedate" => info
.nextduedate
= Some(value
.into()),
168 "message" if value
== "Directory Invalid" => {
169 info
.message
= Some("Invalid Server ID".into())
171 "message" => info
.message
= Some(value
.into()),
172 "validdirectory" => {
173 if value
.split('
,'
).find(is_server_id
) == None
{
174 bail
!("Server ID does not match");
176 info
.serverid
= Some(server_id
.to_owned());
178 "md5hash" => md5hash
= value
.to_owned(),
183 if let SubscriptionStatus
::ACTIVE
= info
.status
{
184 let response_raw
= format
!("{}{}", SHARED_KEY_DATA
, challenge
);
185 let expected
= hex
::encode(&tools
::md5sum(response_raw
.as_bytes())?
);
186 if expected
!= md5hash
{
188 "Subscription API challenge failed, expected {} != got {}",
198 fn test_parse_register_response() -> Result
<(), Error
> {
200 <status>Active</status>
201 <companyname>Proxmox</companyname>
202 <serviceid>41108</serviceid>
203 <productid>71</productid>
204 <productname>Proxmox Backup Server Test Subscription -1 year</productname>
205 <regdate>2020-09-19 00:00:00</regdate>
206 <nextduedate>2021-09-19</nextduedate>
207 <billingcycle>Annually</billingcycle>
208 <validdomain>proxmox.com,www.proxmox.com</validdomain>
209 <validdirectory>830000000123456789ABCDEF00000042</validdirectory>
210 <customfields>Notes=Test Key!</customfields>
212 <md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
214 let key
= "pbst-123456789a".to_string();
215 let server_id
= "830000000123456789ABCDEF00000042".to_string();
216 let checktime
= 1600000000;
217 let salt
= "cf44486bddb6ad0145732642c45b2957";
219 let info
= parse_register_response(
222 server_id
.to_owned(),
231 serverid
: Some(server_id
),
232 status
: SubscriptionStatus
::ACTIVE
,
233 checktime
: Some(checktime
),
234 url
: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
236 nextduedate
: Some("2021-09-19".into()),
237 regdate
: Some("2020-09-19 00:00:00".into()),
238 productname
: Some("Proxmox Backup Server Test Subscription -1 year".into()),
244 /// queries the up to date subscription status and parses the response
245 pub fn check_subscription(key
: String
, server_id
: String
) -> Result
<SubscriptionInfo
, Error
> {
246 let now
= proxmox_time
::epoch_i64();
248 let (response
, challenge
) =
249 proxmox_async
::runtime
::block_on(register_subscription(&key
, &server_id
, now
))
250 .map_err(|err
| format_err
!("Error checking subscription: {}", err
))?
;
252 parse_register_response(&response
, key
, server_id
, now
, &challenge
)
253 .map_err(|err
| format_err
!("Error parsing subscription check response: {}", err
))
256 /// reads in subscription information and does a basic integrity verification
257 pub fn read_subscription() -> Result
<Option
<SubscriptionInfo
>, Error
> {
258 let cfg
= proxmox_sys
::fs
::file_read_optional_string(&SUBSCRIPTION_FN
)?
;
259 let cfg
= if let Some(cfg
) = cfg
{
265 let mut cfg
= cfg
.lines();
267 // first line is key in plain
268 let _key
= if let Some(key
) = cfg
.next() {
273 // second line is checksum of encoded data
274 let checksum
= if let Some(csum
) = cfg
.next() {
280 let encoded
: String
= cfg
.collect
::<String
>();
281 let decoded
= base64
::decode(&encoded
)?
;
282 let decoded
= std
::str::from_utf8(&decoded
)?
;
284 let info
: SubscriptionInfo
= serde_json
::from_str(decoded
)?
;
286 let new_checksum
= format
!(
288 info
.checktime
.unwrap_or(0),
292 let new_checksum
= base64
::encode(tools
::md5sum(new_checksum
.as_bytes())?
);
294 if checksum
!= new_checksum
{
295 return Ok(Some(SubscriptionInfo
{
296 status
: SubscriptionStatus
::INVALID
,
297 message
: Some("checksum mismatch".to_string()),
302 let age
= proxmox_time
::epoch_i64() - info
.checktime
.unwrap_or(0);
304 // allow some delta for DST changes or time syncs, 1.5h
305 return Ok(Some(SubscriptionInfo
{
306 status
: SubscriptionStatus
::INVALID
,
307 message
: Some("last check date too far in the future".to_string()),
310 } else if age
> MAX_LOCAL_KEY_AGE
+ MAX_KEY_CHECK_FAILURE_AGE
{
311 if let SubscriptionStatus
::ACTIVE
= info
.status
{
312 return Ok(Some(SubscriptionInfo
{
313 status
: SubscriptionStatus
::INVALID
,
314 message
: Some("subscription information too old".to_string()),
323 /// writes out subscription status
324 pub fn write_subscription(info
: SubscriptionInfo
) -> Result
<(), Error
> {
325 let key
= info
.key
.to_owned();
326 let server_id
= info
.serverid
.to_owned();
328 let raw
= if info
.key
== None
|| info
.checktime
== None
{
330 } else if let SubscriptionStatus
::NEW
= info
.status
{
331 format
!("{}\n", info
.key
.unwrap())
333 let encoded
= base64
::encode(serde_json
::to_string(&info
)?
);
336 info
.checktime
.unwrap_or(0),
340 let csum
= base64
::encode(tools
::md5sum(csum
.as_bytes())?
);
341 format
!("{}\n{}\n{}\n", info
.key
.unwrap(), csum
, encoded
)
344 let backup_user
= pbs_config
::backup_user()?
;
345 let mode
= nix
::sys
::stat
::Mode
::from_bits_truncate(0o0640);
346 let file_opts
= CreateOptions
::new()
348 .owner(nix
::unistd
::ROOT
)
349 .group(backup_user
.gid
);
351 let subscription_file
= std
::path
::Path
::new(SUBSCRIPTION_FN
);
352 replace_file(subscription_file
, raw
.as_bytes(), file_opts
, true)?
;
354 update_apt_auth(key
, server_id
)?
;
359 /// deletes subscription from server
360 pub fn delete_subscription() -> Result
<(), Error
> {
361 let subscription_file
= std
::path
::Path
::new(SUBSCRIPTION_FN
);
362 nix
::unistd
::unlink(subscription_file
)?
;
363 update_apt_auth(None
, None
)?
;
367 /// updates apt authentication for repo access
368 pub fn update_apt_auth(key
: Option
<String
>, password
: Option
<String
>) -> Result
<(), Error
> {
369 let auth_conf
= std
::path
::Path
::new(APT_AUTH_FN
);
370 match (key
, password
) {
371 (Some(key
), Some(password
)) => {
373 "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n",
376 let mode
= nix
::sys
::stat
::Mode
::from_bits_truncate(0o0640);
377 let file_opts
= CreateOptions
::new().perm(mode
).owner(nix
::unistd
::ROOT
);
379 // we use a namespaced .conf file, so just overwrite..
380 replace_file(auth_conf
, conf
.as_bytes(), file_opts
, true)
381 .map_err(|e
| format_err
!("Error saving apt auth config - {}", e
))?
;
383 _
=> match nix
::unistd
::unlink(auth_conf
) {
385 Err(nix
::errno
::Errno
::ENOENT
) => Ok(()), // ignore not existing
386 Err(err
) => Err(err
),
388 .map_err(|e
| format_err
!("Error clearing apt auth config - {}", e
))?
,