]>
Commit | Line | Data |
---|---|---|
7b22fb25 TL |
1 | use anyhow::{Error, format_err, bail}; |
2 | use lazy_static::lazy_static; | |
7b22fb25 | 3 | use regex::Regex; |
9eb78407 WB |
4 | use serde::{Deserialize, Serialize}; |
5 | use serde_json::json; | |
7b22fb25 | 6 | |
6ef1b649 | 7 | use proxmox_schema::api; |
7b22fb25 | 8 | |
9eb78407 WB |
9 | use proxmox::tools::fs::{replace_file, CreateOptions}; |
10 | use proxmox_http::client::SimpleHttp; | |
11 | ||
12 | use pbs_tools::json::json_object_to_query; | |
13 | ||
013fa2d8 | 14 | use crate::config::node; |
57889533 FG |
15 | use crate::tools::{ |
16 | self, | |
17 | pbs_simple_http, | |
57889533 | 18 | }; |
7b22fb25 TL |
19 | |
20 | /// How long the local key is valid for in between remote checks | |
21 | pub const MAX_LOCAL_KEY_AGE: i64 = 15 * 24 * 3600; | |
22 | const MAX_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600; | |
23 | ||
24 | const SHARED_KEY_DATA: &str = "kjfdlskfhiuewhfk947368"; | |
25 | const SUBSCRIPTION_FN: &str = "/etc/proxmox-backup/subscription"; | |
26 | const APT_AUTH_FN: &str = "/etc/apt/auth.conf.d/pbs.conf"; | |
27 | ||
28 | #[api()] | |
29 | #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] | |
30 | #[serde(rename_all = "lowercase")] | |
31 | /// Subscription status | |
32 | pub enum SubscriptionStatus { | |
33 | // FIXME: remove? | |
34 | /// newly set subscription, not yet checked | |
35 | NEW, | |
36 | /// no subscription set | |
37 | NOTFOUND, | |
38 | /// subscription set and active | |
39 | ACTIVE, | |
40 | /// subscription set but invalid for this server | |
41 | INVALID, | |
42 | } | |
43 | impl Default for SubscriptionStatus { | |
44 | fn default() -> Self { SubscriptionStatus::NOTFOUND } | |
45 | } | |
46 | impl std::fmt::Display for SubscriptionStatus { | |
47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
48 | match self { | |
49 | SubscriptionStatus::NEW => write!(f, "New"), | |
50 | SubscriptionStatus::NOTFOUND => write!(f, "NotFound"), | |
51 | SubscriptionStatus::ACTIVE => write!(f, "Active"), | |
52 | SubscriptionStatus::INVALID => write!(f, "Invalid"), | |
53 | } | |
54 | } | |
55 | } | |
56 | ||
57 | #[api( | |
58 | properties: { | |
59 | status: { | |
60 | type: SubscriptionStatus, | |
61 | }, | |
62 | }, | |
63 | )] | |
64 | #[derive(Debug, Default, PartialEq, Serialize, Deserialize)] | |
65 | #[serde(rename_all="kebab-case")] | |
66 | /// Proxmox subscription information | |
67 | pub struct SubscriptionInfo { | |
68 | /// Subscription status from the last check | |
69 | pub status: SubscriptionStatus, | |
70 | /// the server ID, if permitted to access | |
71 | #[serde(skip_serializing_if="Option::is_none")] | |
72 | pub serverid: Option<String>, | |
73 | /// timestamp of the last check done | |
74 | #[serde(skip_serializing_if="Option::is_none")] | |
75 | pub checktime: Option<i64>, | |
76 | /// the subscription key, if set and permitted to access | |
77 | #[serde(skip_serializing_if="Option::is_none")] | |
78 | pub key: Option<String>, | |
79 | /// a more human readable status message | |
80 | #[serde(skip_serializing_if="Option::is_none")] | |
81 | pub message: Option<String>, | |
82 | /// human readable productname of the set subscription | |
83 | #[serde(skip_serializing_if="Option::is_none")] | |
84 | pub productname: Option<String>, | |
85 | /// register date of the set subscription | |
86 | #[serde(skip_serializing_if="Option::is_none")] | |
87 | pub regdate: Option<String>, | |
88 | /// next due date of the set subscription | |
89 | #[serde(skip_serializing_if="Option::is_none")] | |
90 | pub nextduedate: Option<String>, | |
91 | /// URL to the web shop | |
92 | #[serde(skip_serializing_if="Option::is_none")] | |
93 | pub url: Option<String>, | |
94 | } | |
95 | ||
96 | async fn register_subscription( | |
09faa9ee FG |
97 | key: &str, |
98 | server_id: &str, | |
7b22fb25 TL |
99 | checktime: i64 |
100 | ) -> Result<(String, String), Error> { | |
101 | // WHCMS sample code feeds the key into this, but it's just a challenge, so keep it simple | |
102 | let rand = proxmox::tools::bin_to_hex(&proxmox::sys::linux::random_data(16)?); | |
103 | let challenge = format!("{}{}", checktime, rand); | |
104 | ||
105 | let params = json!({ | |
106 | "licensekey": key, | |
107 | "dir": server_id, | |
108 | "domain": "www.proxmox.com", | |
109 | "ip": "localhost", | |
110 | "check_token": challenge, | |
111 | }); | |
26153589 | 112 | |
013fa2d8 DW |
113 | let proxy_config = if let Ok((node_config, _digest)) = node::config() { |
114 | node_config.http_proxy() | |
115 | } else { | |
116 | None | |
117 | }; | |
118 | ||
57889533 | 119 | let mut client = pbs_simple_http(proxy_config); |
26153589 | 120 | |
aaf4f402 | 121 | let uri = "https://shop.proxmox.com/modules/servers/licensing/verify.php"; |
9eb78407 | 122 | let query = json_object_to_query(params)?; |
26153589 DM |
123 | let response = client.post(uri, Some(query), Some("application/x-www-form-urlencoded")).await?; |
124 | let body = SimpleHttp::response_body_string(response).await?; | |
7b22fb25 TL |
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 | "message" => info.message = Some(value.into()), | |
170 | "validdirectory" => { | |
d8d8af98 | 171 | if value.split(',').find(is_server_id) == None { |
7b22fb25 TL |
172 | bail!("Server ID does not match"); |
173 | } | |
174 | info.serverid = Some(server_id.to_owned()); | |
175 | }, | |
176 | "md5hash" => md5hash = value.to_owned(), | |
177 | _ => (), | |
178 | } | |
179 | } | |
180 | ||
181 | if let SubscriptionStatus::ACTIVE = info.status { | |
182 | let response_raw = format!("{}{}", SHARED_KEY_DATA, challenge); | |
183 | let expected = proxmox::tools::bin_to_hex(&tools::md5sum(response_raw.as_bytes())?); | |
184 | if expected != md5hash { | |
185 | bail!("Subscription API challenge failed, expected {} != got {}", expected, md5hash); | |
186 | } | |
187 | } | |
188 | Ok(info) | |
189 | } | |
190 | ||
191 | #[test] | |
192 | fn test_parse_register_response() -> Result<(), Error> { | |
193 | let response = r#" | |
194 | <status>Active</status> | |
195 | <companyname>Proxmox</companyname> | |
196 | <serviceid>41108</serviceid> | |
197 | <productid>71</productid> | |
198 | <productname>Proxmox Backup Server Test Subscription -1 year</productname> | |
199 | <regdate>2020-09-19 00:00:00</regdate> | |
200 | <nextduedate>2021-09-19</nextduedate> | |
201 | <billingcycle>Annually</billingcycle> | |
202 | <validdomain>proxmox.com,www.proxmox.com</validdomain> | |
203 | <validdirectory>830000000123456789ABCDEF00000042</validdirectory> | |
204 | <customfields>Notes=Test Key!</customfields> | |
205 | <addons></addons> | |
206 | <md5hash>969f4df84fe157ee4f5a2f71950ad154</md5hash> | |
207 | "#; | |
208 | let key = "pbst-123456789a".to_string(); | |
209 | let server_id = "830000000123456789ABCDEF00000042".to_string(); | |
210 | let checktime = 1600000000; | |
211 | let salt = "cf44486bddb6ad0145732642c45b2957"; | |
212 | ||
213 | let info = parse_register_response(response, key.to_owned(), server_id.to_owned(), checktime, salt)?; | |
214 | ||
215 | assert_eq!(info, SubscriptionInfo { | |
216 | key: Some(key), | |
217 | serverid: Some(server_id), | |
218 | status: SubscriptionStatus::ACTIVE, | |
219 | checktime: Some(checktime), | |
220 | url: Some("https://www.proxmox.com/en/proxmox-backup-server/pricing".into()), | |
221 | message: None, | |
222 | nextduedate: Some("2021-09-19".into()), | |
223 | regdate: Some("2020-09-19 00:00:00".into()), | |
224 | productname: Some("Proxmox Backup Server Test Subscription -1 year".into()), | |
225 | }); | |
226 | Ok(()) | |
227 | } | |
228 | ||
d1d74c43 | 229 | /// queries the up to date subscription status and parses the response |
7b22fb25 TL |
230 | pub fn check_subscription(key: String, server_id: String) -> Result<SubscriptionInfo, Error> { |
231 | ||
6ef1b649 | 232 | let now = proxmox_time::epoch_i64(); |
7b22fb25 | 233 | |
d420962f | 234 | let (response, challenge) = pbs_runtime::block_on(register_subscription(&key, &server_id, now)) |
7b22fb25 TL |
235 | .map_err(|err| format_err!("Error checking subscription: {}", err))?; |
236 | ||
237 | parse_register_response(&response, key, server_id, now, &challenge) | |
238 | .map_err(|err| format_err!("Error parsing subscription check response: {}", err)) | |
239 | } | |
240 | ||
241 | /// reads in subscription information and does a basic integrity verification | |
242 | pub fn read_subscription() -> Result<Option<SubscriptionInfo>, Error> { | |
243 | ||
244 | let cfg = proxmox::tools::fs::file_read_optional_string(&SUBSCRIPTION_FN)?; | |
245 | let cfg = if let Some(cfg) = cfg { cfg } else { return Ok(None); }; | |
246 | ||
247 | let mut cfg = cfg.lines(); | |
248 | ||
249 | // first line is key in plain | |
250 | let _key = if let Some(key) = cfg.next() { key } else { return Ok(None) }; | |
251 | // second line is checksum of encoded data | |
252 | let checksum = if let Some(csum) = cfg.next() { csum } else { return Ok(None) }; | |
253 | ||
254 | let encoded: String = cfg.collect::<String>(); | |
255 | let decoded = base64::decode(encoded.to_owned())?; | |
256 | let decoded = std::str::from_utf8(&decoded)?; | |
257 | ||
258 | let info: SubscriptionInfo = serde_json::from_str(decoded)?; | |
259 | ||
260 | let new_checksum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA); | |
261 | let new_checksum = base64::encode(tools::md5sum(new_checksum.as_bytes())?); | |
262 | ||
263 | if checksum != new_checksum { | |
b81818b6 TL |
264 | return Ok(Some( SubscriptionInfo { |
265 | status: SubscriptionStatus::INVALID, | |
266 | message: Some("checksum mismatch".to_string()), | |
267 | ..info | |
268 | })); | |
7b22fb25 TL |
269 | } |
270 | ||
6ef1b649 | 271 | let age = proxmox_time::epoch_i64() - info.checktime.unwrap_or(0); |
7b22fb25 | 272 | if age < -5400 { // allow some delta for DST changes or time syncs, 1.5h |
b81818b6 TL |
273 | return Ok(Some( SubscriptionInfo { |
274 | status: SubscriptionStatus::INVALID, | |
275 | message: Some("last check date too far in the future".to_string()), | |
276 | ..info | |
277 | })); | |
7b22fb25 TL |
278 | } else if age > MAX_LOCAL_KEY_AGE + MAX_KEY_CHECK_FAILURE_AGE { |
279 | if let SubscriptionStatus::ACTIVE = info.status { | |
b81818b6 TL |
280 | return Ok(Some( SubscriptionInfo { |
281 | status: SubscriptionStatus::INVALID, | |
282 | message: Some("subscription information too old".to_string()), | |
283 | ..info | |
284 | })); | |
7b22fb25 TL |
285 | } |
286 | } | |
287 | ||
288 | Ok(Some(info)) | |
289 | } | |
290 | ||
291 | /// writes out subscription status | |
292 | pub fn write_subscription(info: SubscriptionInfo) -> Result<(), Error> { | |
293 | let key = info.key.to_owned(); | |
294 | let server_id = info.serverid.to_owned(); | |
295 | ||
296 | let raw = if info.key == None || info.checktime == None { | |
297 | String::new() | |
298 | } else if let SubscriptionStatus::NEW = info.status { | |
299 | format!("{}\n", info.key.unwrap()) | |
300 | } else { | |
301 | let encoded = base64::encode(serde_json::to_string(&info)?); | |
302 | let csum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA); | |
303 | let csum = base64::encode(tools::md5sum(csum.as_bytes())?); | |
304 | format!("{}\n{}\n{}\n", info.key.unwrap(), csum, encoded) | |
305 | }; | |
306 | ||
21211748 | 307 | let backup_user = pbs_config::backup_user()?; |
7b22fb25 TL |
308 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); |
309 | let file_opts = CreateOptions::new() | |
310 | .perm(mode) | |
311 | .owner(nix::unistd::ROOT) | |
312 | .group(backup_user.gid); | |
313 | ||
314 | let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); | |
e0a19d33 | 315 | replace_file(subscription_file, raw.as_bytes(), file_opts, true)?; |
7b22fb25 TL |
316 | |
317 | update_apt_auth(key, server_id)?; | |
318 | ||
319 | Ok(()) | |
320 | } | |
321 | ||
322 | /// deletes subscription from server | |
323 | pub fn delete_subscription() -> Result<(), Error> { | |
324 | let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); | |
325 | nix::unistd::unlink(subscription_file)?; | |
326 | update_apt_auth(None, None)?; | |
327 | Ok(()) | |
328 | } | |
329 | ||
d1d74c43 | 330 | /// updates apt authentication for repo access |
7b22fb25 TL |
331 | pub fn update_apt_auth(key: Option<String>, password: Option<String>) -> Result<(), Error> { |
332 | let auth_conf = std::path::Path::new(APT_AUTH_FN); | |
333 | match (key, password) { | |
334 | (Some(key), Some(password)) => { | |
335 | let conf = format!( | |
f23497b0 | 336 | "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n", |
7b22fb25 TL |
337 | key, |
338 | password, | |
339 | ); | |
340 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); | |
341 | let file_opts = CreateOptions::new() | |
342 | .perm(mode) | |
343 | .owner(nix::unistd::ROOT); | |
344 | ||
345 | // we use a namespaced .conf file, so just overwrite.. | |
e0a19d33 | 346 | replace_file(auth_conf, conf.as_bytes(), file_opts, true) |
7b22fb25 TL |
347 | .map_err(|e| format_err!("Error saving apt auth config - {}", e))?; |
348 | } | |
caf76ec5 DC |
349 | _ => match nix::unistd::unlink(auth_conf) { |
350 | Ok(()) => Ok(()), | |
351 | Err(nix::Error::Sys(nix::errno::Errno::ENOENT)) => Ok(()), // ignore not existing | |
352 | Err(err) => Err(err), | |
353 | }.map_err(|e| format_err!("Error clearing apt auth config - {}", e))?, | |
7b22fb25 TL |
354 | } |
355 | Ok(()) | |
356 | } |