]> git.proxmox.com Git - proxmox-backup.git/blob - src/tools/subscription.rs
08917284da09df60f2a1d36435f220c8b7cf8115
[proxmox-backup.git] / src / tools / subscription.rs
1 use anyhow::{Error, format_err, bail};
2 use lazy_static::lazy_static;
3 use serde_json::json;
4 use serde::{Deserialize, Serialize};
5 use regex::Regex;
6
7 use proxmox::api::api;
8
9 use crate::config::node;
10 use crate::tools::{
11 self,
12 pbs_simple_http,
13 };
14 use proxmox::tools::fs::{replace_file, CreateOptions};
15 use proxmox_http::client::SimpleHttp;
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 { SubscriptionStatus::NOTFOUND }
42 }
43 impl std::fmt::Display for SubscriptionStatus {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 SubscriptionStatus::NEW => write!(f, "New"),
47 SubscriptionStatus::NOTFOUND => write!(f, "NotFound"),
48 SubscriptionStatus::ACTIVE => write!(f, "Active"),
49 SubscriptionStatus::INVALID => write!(f, "Invalid"),
50 }
51 }
52 }
53
54 #[api(
55 properties: {
56 status: {
57 type: SubscriptionStatus,
58 },
59 },
60 )]
61 #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
62 #[serde(rename_all="kebab-case")]
63 /// Proxmox subscription information
64 pub struct SubscriptionInfo {
65 /// Subscription status from the last check
66 pub status: SubscriptionStatus,
67 /// the server ID, if permitted to access
68 #[serde(skip_serializing_if="Option::is_none")]
69 pub serverid: Option<String>,
70 /// timestamp of the last check done
71 #[serde(skip_serializing_if="Option::is_none")]
72 pub checktime: Option<i64>,
73 /// the subscription key, if set and permitted to access
74 #[serde(skip_serializing_if="Option::is_none")]
75 pub key: Option<String>,
76 /// a more human readable status message
77 #[serde(skip_serializing_if="Option::is_none")]
78 pub message: Option<String>,
79 /// human readable productname of the set subscription
80 #[serde(skip_serializing_if="Option::is_none")]
81 pub productname: Option<String>,
82 /// register date of the set subscription
83 #[serde(skip_serializing_if="Option::is_none")]
84 pub regdate: Option<String>,
85 /// next due date of the set subscription
86 #[serde(skip_serializing_if="Option::is_none")]
87 pub nextduedate: Option<String>,
88 /// URL to the web shop
89 #[serde(skip_serializing_if="Option::is_none")]
90 pub url: Option<String>,
91 }
92
93 async fn register_subscription(
94 key: &str,
95 server_id: &str,
96 checktime: i64
97 ) -> Result<(String, String), Error> {
98 // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple
99 let rand = proxmox::tools::bin_to_hex(&proxmox::sys::linux::random_data(16)?);
100 let challenge = format!("{}{}", checktime, rand);
101
102 let params = json!({
103 "licensekey": key,
104 "dir": server_id,
105 "domain": "www.proxmox.com",
106 "ip": "localhost",
107 "check_token": challenge,
108 });
109
110 let proxy_config = if let Ok((node_config, _digest)) = node::config() {
111 node_config.http_proxy()
112 } else {
113 None
114 };
115
116 let mut client = pbs_simple_http(proxy_config);
117
118 let uri = "https://shop.maurer-it.com/modules/servers/licensing/verify.php";
119 let query = tools::json_object_to_query(params)?;
120 let response = client.post(uri, Some(query), Some("application/x-www-form-urlencoded")).await?;
121 let body = SimpleHttp::response_body_string(response).await?;
122
123 Ok((body, challenge))
124 }
125
126 fn parse_status(value: &str) -> SubscriptionStatus {
127 match value.to_lowercase().as_str() {
128 "active" => SubscriptionStatus::ACTIVE,
129 "new" => SubscriptionStatus::NEW,
130 "notfound" => SubscriptionStatus::NOTFOUND,
131 "invalid" => SubscriptionStatus::INVALID,
132 _ => SubscriptionStatus::INVALID,
133 }
134 }
135
136 fn parse_register_response(
137 body: &str,
138 key: String,
139 server_id: String,
140 checktime: i64,
141 challenge: &str,
142 ) -> Result<SubscriptionInfo, Error> {
143 lazy_static! {
144 static ref ATTR_RE: Regex = Regex::new(r"<([^>]+)>([^<]+)</[^>]+>").unwrap();
145 }
146
147 let mut info = SubscriptionInfo {
148 key: Some(key),
149 status: SubscriptionStatus::NOTFOUND,
150 checktime: Some(checktime),
151 url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
152 ..Default::default()
153 };
154 let mut md5hash = String::new();
155 let is_server_id = |id: &&str| *id == server_id;
156
157 for caps in ATTR_RE.captures_iter(body) {
158 let (key, value) = (&caps[1], &caps[2]);
159 match key {
160 "status" => info.status = parse_status(value),
161 "productname" => info.productname = Some(value.into()),
162 "regdate" => info.regdate = Some(value.into()),
163 "nextduedate" => info.nextduedate = Some(value.into()),
164 "message" if value == "Directory Invalid" =>
165 info.message = Some("Invalid Server ID".into()),
166 "message" => info.message = Some(value.into()),
167 "validdirectory" => {
168 if value.split(',').find(is_server_id) == None {
169 bail!("Server ID does not match");
170 }
171 info.serverid = Some(server_id.to_owned());
172 },
173 "md5hash" => md5hash = value.to_owned(),
174 _ => (),
175 }
176 }
177
178 if let SubscriptionStatus::ACTIVE = info.status {
179 let response_raw = format!("{}{}", SHARED_KEY_DATA, challenge);
180 let expected = proxmox::tools::bin_to_hex(&tools::md5sum(response_raw.as_bytes())?);
181 if expected != md5hash {
182 bail!("Subscription API challenge failed, expected {} != got {}", expected, md5hash);
183 }
184 }
185 Ok(info)
186 }
187
188 #[test]
189 fn test_parse_register_response() -> Result<(), Error> {
190 let response = r#"
191 <status>Active</status>
192 <companyname>Proxmox</companyname>
193 <serviceid>41108</serviceid>
194 <productid>71</productid>
195 <productname>Proxmox Backup Server Test Subscription -1 year</productname>
196 <regdate>2020-09-19 00:00:00</regdate>
197 <nextduedate>2021-09-19</nextduedate>
198 <billingcycle>Annually</billingcycle>
199 <validdomain>proxmox.com,www.proxmox.com</validdomain>
200 <validdirectory>830000000123456789ABCDEF00000042</validdirectory>
201 <customfields>Notes=Test Key!</customfields>
202 <addons></addons>
203 <md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash>
204 "#;
205 let key = "pbst-123456789a".to_string();
206 let server_id = "830000000123456789ABCDEF00000042".to_string();
207 let checktime = 1600000000;
208 let salt = "cf44486bddb6ad0145732642c45b2957";
209
210 let info = parse_register_response(response, key.to_owned(), server_id.to_owned(), checktime, salt)?;
211
212 assert_eq!(info, SubscriptionInfo {
213 key: Some(key),
214 serverid: Some(server_id),
215 status: SubscriptionStatus::ACTIVE,
216 checktime: Some(checktime),
217 url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()),
218 message: None,
219 nextduedate: Some("2021-09-19".into()),
220 regdate: Some("2020-09-19 00:00:00".into()),
221 productname: Some("Proxmox Backup Server Test Subscription -1 year".into()),
222 });
223 Ok(())
224 }
225
226 /// queries the up to date subscription status and parses the response
227 pub fn check_subscription(key: String, server_id: String) -> Result<SubscriptionInfo, Error> {
228
229 let now = proxmox::tools::time::epoch_i64();
230
231 let (response, challenge) = tools::runtime::block_on(register_subscription(&key, &server_id, now))
232 .map_err(|err| format_err!("Error checking subscription: {}", err))?;
233
234 parse_register_response(&response, key, server_id, now, &challenge)
235 .map_err(|err| format_err!("Error parsing subscription check response: {}", err))
236 }
237
238 /// reads in subscription information and does a basic integrity verification
239 pub fn read_subscription() -> Result<Option<SubscriptionInfo>, Error> {
240
241 let cfg = proxmox::tools::fs::file_read_optional_string(&SUBSCRIPTION_FN)?;
242 let cfg = if let Some(cfg) = cfg { cfg } else { return Ok(None); };
243
244 let mut cfg = cfg.lines();
245
246 // first line is key in plain
247 let _key = if let Some(key) = cfg.next() { key } else { return Ok(None) };
248 // second line is checksum of encoded data
249 let checksum = if let Some(csum) = cfg.next() { csum } else { return Ok(None) };
250
251 let encoded: String = cfg.collect::<String>();
252 let decoded = base64::decode(encoded.to_owned())?;
253 let decoded = std::str::from_utf8(&decoded)?;
254
255 let info: SubscriptionInfo = serde_json::from_str(decoded)?;
256
257 let new_checksum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA);
258 let new_checksum = base64::encode(tools::md5sum(new_checksum.as_bytes())?);
259
260 if checksum != new_checksum {
261 bail!("stored checksum doesn't matches computed one '{}' != '{}'", checksum, new_checksum);
262 }
263
264 let age = proxmox::tools::time::epoch_i64() - info.checktime.unwrap_or(0);
265 if age < -5400 { // allow some delta for DST changes or time syncs, 1.5h
266 bail!("Last check time to far in the future.");
267 } else if age > MAX_LOCAL_KEY_AGE + MAX_KEY_CHECK_FAILURE_AGE {
268 if let SubscriptionStatus::ACTIVE = info.status {
269 bail!("subscription information too old");
270 }
271 }
272
273 Ok(Some(info))
274 }
275
276 /// writes out subscription status
277 pub fn write_subscription(info: SubscriptionInfo) -> Result<(), Error> {
278 let key = info.key.to_owned();
279 let server_id = info.serverid.to_owned();
280
281 let raw = if info.key == None || info.checktime == None {
282 String::new()
283 } else if let SubscriptionStatus::NEW = info.status {
284 format!("{}\n", info.key.unwrap())
285 } else {
286 let encoded = base64::encode(serde_json::to_string(&info)?);
287 let csum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA);
288 let csum = base64::encode(tools::md5sum(csum.as_bytes())?);
289 format!("{}\n{}\n{}\n", info.key.unwrap(), csum, encoded)
290 };
291
292 let backup_user = crate::backup::backup_user()?;
293 let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
294 let file_opts = CreateOptions::new()
295 .perm(mode)
296 .owner(nix::unistd::ROOT)
297 .group(backup_user.gid);
298
299 let subscription_file = std::path::Path::new(SUBSCRIPTION_FN);
300 replace_file(subscription_file, raw.as_bytes(), file_opts)?;
301
302 update_apt_auth(key, server_id)?;
303
304 Ok(())
305 }
306
307 /// deletes subscription from server
308 pub fn delete_subscription() -> Result<(), Error> {
309 let subscription_file = std::path::Path::new(SUBSCRIPTION_FN);
310 nix::unistd::unlink(subscription_file)?;
311 update_apt_auth(None, None)?;
312 Ok(())
313 }
314
315 /// updates apt authentication for repo access
316 pub fn update_apt_auth(key: Option<String>, password: Option<String>) -> Result<(), Error> {
317 let auth_conf = std::path::Path::new(APT_AUTH_FN);
318 match (key, password) {
319 (Some(key), Some(password)) => {
320 let conf = format!(
321 "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n",
322 key,
323 password,
324 );
325 let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
326 let file_opts = CreateOptions::new()
327 .perm(mode)
328 .owner(nix::unistd::ROOT);
329
330 // we use a namespaced .conf file, so just overwrite..
331 replace_file(auth_conf, conf.as_bytes(), file_opts)
332 .map_err(|e| format_err!("Error saving apt auth config - {}", e))?;
333 }
334 _ => match nix::unistd::unlink(auth_conf) {
335 Ok(()) => Ok(()),
336 Err(nix::Error::Sys(nix::errno::Errno::ENOENT)) => Ok(()), // ignore not existing
337 Err(err) => Err(err),
338 }.map_err(|e| format_err!("Error clearing apt auth config - {}", e))?,
339 }
340 Ok(())
341 }