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