]> git.proxmox.com Git - proxmox-backup.git/blob - src/tools/subscription.rs
14b95f30bf280d91d5d34477febd3523cbd27a4a
[proxmox-backup.git] / src / tools / subscription.rs
1 use anyhow::{bail, format_err, Error};
2 use lazy_static::lazy_static;
3 use regex::Regex;
4 use serde::{Deserialize, Serialize};
5 use serde_json::json;
6
7 use proxmox_schema::api;
8
9 use proxmox_http::client::SimpleHttp;
10 use proxmox_sys::fs::{replace_file, CreateOptions};
11
12 use pbs_tools::json::json_object_to_query;
13
14 use crate::config::node;
15 use crate::tools::{self, pbs_simple_http};
16
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;
20
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";
24
25 #[api()]
26 #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
27 #[serde(rename_all = "lowercase")]
28 /// Subscription status
29 pub enum SubscriptionStatus {
30 // FIXME: remove?
31 /// newly set subscription, not yet checked
32 NEW,
33 /// no subscription set
34 NOTFOUND,
35 /// subscription set and active
36 ACTIVE,
37 /// subscription set but invalid for this server
38 INVALID,
39 }
40 impl Default for SubscriptionStatus {
41 fn default() -> Self {
42 SubscriptionStatus::NOTFOUND
43 }
44 }
45 impl std::fmt::Display for SubscriptionStatus {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 SubscriptionStatus::NEW => write!(f, "New"),
49 SubscriptionStatus::NOTFOUND => write!(f, "NotFound"),
50 SubscriptionStatus::ACTIVE => write!(f, "Active"),
51 SubscriptionStatus::INVALID => write!(f, "Invalid"),
52 }
53 }
54 }
55
56 #[api(
57 properties: {
58 status: {
59 type: SubscriptionStatus,
60 },
61 },
62 )]
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>,
93 }
94
95 async fn register_subscription(
96 key: &str,
97 server_id: &str,
98 checktime: i64,
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);
103
104 let params = json!({
105 "licensekey": key,
106 "dir": server_id,
107 "domain": "www.proxmox.com",
108 "ip": "localhost",
109 "check_token": challenge,
110 });
111
112 let proxy_config = if let Ok((node_config, _digest)) = node::config() {
113 node_config.http_proxy()
114 } else {
115 None
116 };
117
118 let client = pbs_simple_http(proxy_config);
119
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"))
124 .await?;
125 let body = SimpleHttp::response_body_string(response).await?;
126
127 Ok((body, challenge))
128 }
129
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,
137 }
138 }
139
140 fn parse_register_response(
141 body: &str,
142 key: String,
143 server_id: String,
144 checktime: i64,
145 challenge: &str,
146 ) -> Result<SubscriptionInfo, Error> {
147 lazy_static! {
148 static ref ATTR_RE: Regex = Regex::new(r"<([^>]+)>([^<]+)</[^>]+>").unwrap();
149 }
150
151 let mut info = SubscriptionInfo {
152 key: Some(key),
153 status: SubscriptionStatus::NOTFOUND,
154 checktime: Some(checktime),
155 url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
156 ..Default::default()
157 };
158 let mut md5hash = String::new();
159 let is_server_id = |id: &&str| *id == server_id;
160
161 for caps in ATTR_RE.captures_iter(body) {
162 let (key, value) = (&caps[1], &caps[2]);
163 match key {
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())
170 }
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");
175 }
176 info.serverid = Some(server_id.to_owned());
177 }
178 "md5hash" => md5hash = value.to_owned(),
179 _ => (),
180 }
181 }
182
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 {
187 bail!(
188 "Subscription API challenge failed, expected {} != got {}",
189 expected,
190 md5hash
191 );
192 }
193 }
194 Ok(info)
195 }
196
197 #[test]
198 fn test_parse_register_response() -> Result<(), Error> {
199 let response = r#"
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>
211 <addons></addons>
212 <md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
213 "#;
214 let key = "pbst-123456789a".to_string();
215 let server_id = "830000000123456789ABCDEF00000042".to_string();
216 let checktime = 1600000000;
217 let salt = "cf44486bddb6ad0145732642c45b2957";
218
219 let info = parse_register_response(
220 response,
221 key.to_owned(),
222 server_id.to_owned(),
223 checktime,
224 salt,
225 )?;
226
227 assert_eq!(
228 info,
229 SubscriptionInfo {
230 key: Some(key),
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()),
235 message: None,
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()),
239 }
240 );
241 Ok(())
242 }
243
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();
247
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))?;
251
252 parse_register_response(&response, key, server_id, now, &challenge)
253 .map_err(|err| format_err!("Error parsing subscription check response: {}", err))
254 }
255
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 {
260 cfg
261 } else {
262 return Ok(None);
263 };
264
265 let mut cfg = cfg.lines();
266
267 // first line is key in plain
268 let _key = if let Some(key) = cfg.next() {
269 key
270 } else {
271 return Ok(None);
272 };
273 // second line is checksum of encoded data
274 let checksum = if let Some(csum) = cfg.next() {
275 csum
276 } else {
277 return Ok(None);
278 };
279
280 let encoded: String = cfg.collect::<String>();
281 let decoded = base64::decode(&encoded)?;
282 let decoded = std::str::from_utf8(&decoded)?;
283
284 let info: SubscriptionInfo = serde_json::from_str(decoded)?;
285
286 let new_checksum = format!(
287 "{}{}{}",
288 info.checktime.unwrap_or(0),
289 encoded,
290 SHARED_KEY_DATA
291 );
292 let new_checksum = base64::encode(tools::md5sum(new_checksum.as_bytes())?);
293
294 if checksum != new_checksum {
295 return Ok(Some(SubscriptionInfo {
296 status: SubscriptionStatus::INVALID,
297 message: Some("checksum mismatch".to_string()),
298 ..info
299 }));
300 }
301
302 let age = proxmox_time::epoch_i64() - info.checktime.unwrap_or(0);
303 if age < -5400 {
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()),
308 ..info
309 }));
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()),
315 ..info
316 }));
317 }
318 }
319
320 Ok(Some(info))
321 }
322
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();
327
328 let raw = if info.key == None || info.checktime == None {
329 String::new()
330 } else if let SubscriptionStatus::NEW = info.status {
331 format!("{}\n", info.key.unwrap())
332 } else {
333 let encoded = base64::encode(serde_json::to_string(&info)?);
334 let csum = format!(
335 "{}{}{}",
336 info.checktime.unwrap_or(0),
337 encoded,
338 SHARED_KEY_DATA
339 );
340 let csum = base64::encode(tools::md5sum(csum.as_bytes())?);
341 format!("{}\n{}\n{}\n", info.key.unwrap(), csum, encoded)
342 };
343
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()
347 .perm(mode)
348 .owner(nix::unistd::ROOT)
349 .group(backup_user.gid);
350
351 let subscription_file = std::path::Path::new(SUBSCRIPTION_FN);
352 replace_file(subscription_file, raw.as_bytes(), file_opts, true)?;
353
354 update_apt_auth(key, server_id)?;
355
356 Ok(())
357 }
358
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)?;
364 Ok(())
365 }
366
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)) => {
372 let conf = format!(
373 "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n",
374 key, password,
375 );
376 let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
377 let file_opts = CreateOptions::new().perm(mode).owner(nix::unistd::ROOT);
378
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))?;
382 }
383 _ => match nix::unistd::unlink(auth_conf) {
384 Ok(()) => Ok(()),
385 Err(nix::errno::Errno::ENOENT) => Ok(()), // ignore not existing
386 Err(err) => Err(err),
387 }
388 .map_err(|e| format_err!("Error clearing apt auth config - {}", e))?,
389 }
390 Ok(())
391 }