]>
Commit | Line | Data |
---|---|---|
7b22fb25 TL |
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 | ||
013fa2d8 | 9 | use crate::config::node; |
57889533 FG |
10 | use crate::tools::{ |
11 | self, | |
12 | pbs_simple_http, | |
57889533 | 13 | }; |
7b22fb25 | 14 | use proxmox::tools::fs::{replace_file, CreateOptions}; |
1d781c5b | 15 | use proxmox_http::client::SimpleHttp; |
7b22fb25 TL |
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( | |
09faa9ee FG |
94 | key: &str, |
95 | server_id: &str, | |
7b22fb25 TL |
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 | }); | |
26153589 | 109 | |
013fa2d8 DW |
110 | let proxy_config = if let Ok((node_config, _digest)) = node::config() { |
111 | node_config.http_proxy() | |
112 | } else { | |
113 | None | |
114 | }; | |
115 | ||
57889533 | 116 | let mut client = pbs_simple_http(proxy_config); |
26153589 | 117 | |
7b22fb25 TL |
118 | let uri = "https://shop.maurer-it.com/modules/servers/licensing/verify.php"; |
119 | let query = tools::json_object_to_query(params)?; | |
26153589 DM |
120 | let response = client.post(uri, Some(query), Some("application/x-www-form-urlencoded")).await?; |
121 | let body = SimpleHttp::response_body_string(response).await?; | |
7b22fb25 TL |
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" => { | |
d8d8af98 | 168 | if value.split(',').find(is_server_id) == None { |
7b22fb25 TL |
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 | ||
d1d74c43 | 226 | /// queries the up to date subscription status and parses the response |
7b22fb25 TL |
227 | pub fn check_subscription(key: String, server_id: String) -> Result<SubscriptionInfo, Error> { |
228 | ||
229 | let now = proxmox::tools::time::epoch_i64(); | |
230 | ||
d420962f | 231 | let (response, challenge) = pbs_runtime::block_on(register_subscription(&key, &server_id, now)) |
7b22fb25 TL |
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 { | |
b81818b6 TL |
261 | return Ok(Some( SubscriptionInfo { |
262 | status: SubscriptionStatus::INVALID, | |
263 | message: Some("checksum mismatch".to_string()), | |
264 | ..info | |
265 | })); | |
7b22fb25 TL |
266 | } |
267 | ||
268 | let age = proxmox::tools::time::epoch_i64() - info.checktime.unwrap_or(0); | |
269 | if age < -5400 { // allow some delta for DST changes or time syncs, 1.5h | |
b81818b6 TL |
270 | return Ok(Some( SubscriptionInfo { |
271 | status: SubscriptionStatus::INVALID, | |
272 | message: Some("last check date too far in the future".to_string()), | |
273 | ..info | |
274 | })); | |
7b22fb25 TL |
275 | } else if age > MAX_LOCAL_KEY_AGE + MAX_KEY_CHECK_FAILURE_AGE { |
276 | if let SubscriptionStatus::ACTIVE = info.status { | |
b81818b6 TL |
277 | return Ok(Some( SubscriptionInfo { |
278 | status: SubscriptionStatus::INVALID, | |
279 | message: Some("subscription information too old".to_string()), | |
280 | ..info | |
281 | })); | |
7b22fb25 TL |
282 | } |
283 | } | |
284 | ||
285 | Ok(Some(info)) | |
286 | } | |
287 | ||
288 | /// writes out subscription status | |
289 | pub fn write_subscription(info: SubscriptionInfo) -> Result<(), Error> { | |
290 | let key = info.key.to_owned(); | |
291 | let server_id = info.serverid.to_owned(); | |
292 | ||
293 | let raw = if info.key == None || info.checktime == None { | |
294 | String::new() | |
295 | } else if let SubscriptionStatus::NEW = info.status { | |
296 | format!("{}\n", info.key.unwrap()) | |
297 | } else { | |
298 | let encoded = base64::encode(serde_json::to_string(&info)?); | |
299 | let csum = format!("{}{}{}", info.checktime.unwrap_or(0), encoded, SHARED_KEY_DATA); | |
300 | let csum = base64::encode(tools::md5sum(csum.as_bytes())?); | |
301 | format!("{}\n{}\n{}\n", info.key.unwrap(), csum, encoded) | |
302 | }; | |
303 | ||
304 | let backup_user = crate::backup::backup_user()?; | |
305 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); | |
306 | let file_opts = CreateOptions::new() | |
307 | .perm(mode) | |
308 | .owner(nix::unistd::ROOT) | |
309 | .group(backup_user.gid); | |
310 | ||
311 | let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); | |
312 | replace_file(subscription_file, raw.as_bytes(), file_opts)?; | |
313 | ||
314 | update_apt_auth(key, server_id)?; | |
315 | ||
316 | Ok(()) | |
317 | } | |
318 | ||
319 | /// deletes subscription from server | |
320 | pub fn delete_subscription() -> Result<(), Error> { | |
321 | let subscription_file = std::path::Path::new(SUBSCRIPTION_FN); | |
322 | nix::unistd::unlink(subscription_file)?; | |
323 | update_apt_auth(None, None)?; | |
324 | Ok(()) | |
325 | } | |
326 | ||
d1d74c43 | 327 | /// updates apt authentication for repo access |
7b22fb25 TL |
328 | pub fn update_apt_auth(key: Option<String>, password: Option<String>) -> Result<(), Error> { |
329 | let auth_conf = std::path::Path::new(APT_AUTH_FN); | |
330 | match (key, password) { | |
331 | (Some(key), Some(password)) => { | |
332 | let conf = format!( | |
f23497b0 | 333 | "machine enterprise.proxmox.com/debian/pbs\n login {}\n password {}\n", |
7b22fb25 TL |
334 | key, |
335 | password, | |
336 | ); | |
337 | let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); | |
338 | let file_opts = CreateOptions::new() | |
339 | .perm(mode) | |
340 | .owner(nix::unistd::ROOT); | |
341 | ||
342 | // we use a namespaced .conf file, so just overwrite.. | |
343 | replace_file(auth_conf, conf.as_bytes(), file_opts) | |
344 | .map_err(|e| format_err!("Error saving apt auth config - {}", e))?; | |
345 | } | |
caf76ec5 DC |
346 | _ => match nix::unistd::unlink(auth_conf) { |
347 | Ok(()) => Ok(()), | |
348 | Err(nix::Error::Sys(nix::errno::Errno::ENOENT)) => Ok(()), // ignore not existing | |
349 | Err(err) => Err(err), | |
350 | }.map_err(|e| format_err!("Error clearing apt auth config - {}", e))?, | |
7b22fb25 TL |
351 | } |
352 | Ok(()) | |
353 | } |