]> git.proxmox.com Git - proxmox-backup.git/blame - src/tools/subscription.rs
use new fsync parameter to replace_file and atomic_open_or_create
[proxmox-backup.git] / src / tools / subscription.rs
CommitLineData
7b22fb25
TL
1use anyhow::{Error, format_err, bail};
2use lazy_static::lazy_static;
7b22fb25 3use regex::Regex;
9eb78407
WB
4use serde::{Deserialize, Serialize};
5use serde_json::json;
7b22fb25 6
6ef1b649 7use proxmox_schema::api;
7b22fb25 8
9eb78407
WB
9use proxmox::tools::fs::{replace_file, CreateOptions};
10use proxmox_http::client::SimpleHttp;
11
12use pbs_tools::json::json_object_to_query;
13
013fa2d8 14use crate::config::node;
57889533
FG
15use 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
21pub const MAX_LOCAL_KEY_AGE: i64 = 15 * 24 * 3600;
22const MAX_KEY_CHECK_FAILURE_AGE: i64 = 5 * 24 * 3600;
23
24const SHARED_KEY_DATA: &str = "kjfdlskfhiuewhfk947368";
25const SUBSCRIPTION_FN: &str = "/etc/proxmox-backup/subscription";
26const 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
32pub 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}
43impl Default for SubscriptionStatus {
44 fn default() -> Self { SubscriptionStatus::NOTFOUND }
45}
46impl 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
67pub 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
96async 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
129fn 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
139fn 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]
192fn 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
230pub 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
242pub 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
292pub 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
323pub 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
331pub 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}