]> git.proxmox.com Git - pve-installer.git/blob - proxmox-tui-installer/src/main.rs
15340e11246ce0b78e277ff19ee97895bff28014
[pve-installer.git] / proxmox-tui-installer / src / main.rs
1 #![forbid(unsafe_code)]
2
3 use std::{collections::HashMap, env, net::IpAddr};
4
5 use cursive::{
6 event::Event,
7 theme::{ColorStyle, Effect, PaletteColor, Style},
8 view::{Nameable, Offset, Resizable, ViewWrapper},
9 views::{
10 Button, Checkbox, Dialog, DummyView, EditView, Layer, LinearLayout, PaddedView, Panel,
11 ResizedView, ScrollView, SelectView, StackView, TextView, ViewRef,
12 },
13 Cursive, CursiveRunnable, ScreenId, View, XY,
14 };
15
16 use regex::Regex;
17
18 mod options;
19 use options::InstallerOptions;
20
21 use proxmox_installer_common::{
22 options::{BootdiskOptions, NetworkOptions, PasswordOptions, TimezoneOptions},
23 setup::{installer_setup, LocaleInfo, ProxmoxProduct, RuntimeInfo, SetupInfo},
24 utils::Fqdn,
25 };
26
27 mod setup;
28
29 mod system;
30
31 mod views;
32 use views::{
33 BootdiskOptionsView, CidrAddressEditView, FormView, InstallProgressView, TableView,
34 TableViewItem, TimezoneOptionsView,
35 };
36
37 // TextView::center() seems to garble the first two lines, so fix it manually here.
38 const PROXMOX_LOGO: &str = r#"
39 ____
40 | _ \ _ __ _____ ___ __ ___ _____ __
41 | |_) | '__/ _ \ \/ / '_ ` _ \ / _ \ \/ /
42 | __/| | | (_) > <| | | | | | (_) > <
43 |_| |_| \___/_/\_\_| |_| |_|\___/_/\_\ "#;
44
45 struct InstallerView {
46 view: ResizedView<Dialog>,
47 }
48
49 impl InstallerView {
50 pub fn new<T: View>(
51 state: &InstallerState,
52 view: T,
53 next_cb: Box<dyn Fn(&mut Cursive)>,
54 focus_next: bool,
55 ) -> Self {
56 let mut bbar = LinearLayout::horizontal()
57 .child(abort_install_button())
58 .child(DummyView.full_width())
59 .child(Button::new("Previous", switch_to_prev_screen))
60 .child(DummyView)
61 .child(Button::new("Next", next_cb));
62 let _ = bbar.set_focus_index(4); // ignore errors
63 let mut inner = LinearLayout::vertical()
64 .child(PaddedView::lrtb(0, 0, 1, 1, view))
65 .child(PaddedView::lrtb(1, 1, 0, 0, bbar));
66 if focus_next {
67 let _ = inner.set_focus_index(1); // ignore errors
68 }
69
70 Self::with_raw(state, inner)
71 }
72
73 pub fn with_raw(state: &InstallerState, view: impl View) -> Self {
74 let setup = &state.setup_info;
75
76 let title = format!(
77 "{} ({}-{}) Installer",
78 setup.config.fullname, setup.iso_info.release, setup.iso_info.isorelease
79 );
80
81 let inner = Dialog::around(view).title(title);
82
83 Self {
84 // Limit the maximum to something reasonable, such that it won't get spread out much
85 // depending on the screen.
86 view: ResizedView::with_max_size((120, 40), inner),
87 }
88 }
89 }
90
91 impl ViewWrapper for InstallerView {
92 cursive::wrap_impl!(self.view: ResizedView<Dialog>);
93 }
94
95 struct InstallerBackgroundView {
96 view: StackView,
97 }
98
99 impl InstallerBackgroundView {
100 pub fn new() -> Self {
101 let style = Style {
102 effects: Effect::Bold.into(),
103 color: ColorStyle::back(PaletteColor::View),
104 };
105
106 let mut view = StackView::new();
107 view.add_fullscreen_layer(Layer::with_color(
108 DummyView
109 .full_width()
110 .fixed_height(PROXMOX_LOGO.lines().count() + 1),
111 ColorStyle::back(PaletteColor::View),
112 ));
113 view.add_transparent_layer_at(
114 XY {
115 x: Offset::Center,
116 y: Offset::Absolute(0),
117 },
118 TextView::new(PROXMOX_LOGO).style(style),
119 );
120
121 Self { view }
122 }
123 }
124
125 impl ViewWrapper for InstallerBackgroundView {
126 cursive::wrap_impl!(self.view: StackView);
127 }
128
129 #[derive(Clone, Eq, Hash, PartialEq)]
130 enum InstallerStep {
131 Licence,
132 Bootdisk,
133 Timezone,
134 Password,
135 Network,
136 Summary,
137 Install,
138 }
139
140 #[derive(Clone)]
141 struct InstallerState {
142 options: InstallerOptions,
143 setup_info: SetupInfo,
144 runtime_info: RuntimeInfo,
145 locales: LocaleInfo,
146 steps: HashMap<InstallerStep, ScreenId>,
147 in_test_mode: bool,
148 }
149
150 fn main() {
151 let mut siv = cursive::termion();
152
153 let in_test_mode = match env::args().nth(1).as_deref() {
154 Some("-t") => true,
155
156 // Always force the test directory in debug builds
157 _ => cfg!(debug_assertions),
158 };
159
160 let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) {
161 Ok(result) => result,
162 Err(err) => initial_setup_error(&mut siv, &err),
163 };
164
165 siv.clear_global_callbacks(Event::CtrlChar('c'));
166 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
167
168 siv.set_user_data(InstallerState {
169 options: InstallerOptions {
170 bootdisk: BootdiskOptions::defaults_from(&runtime_info.disks[0]),
171 timezone: TimezoneOptions::defaults_from(&runtime_info, &locales),
172 password: Default::default(),
173 network: NetworkOptions::defaults_from(&setup_info, &runtime_info.network),
174 autoreboot: false,
175 },
176 setup_info,
177 runtime_info,
178 locales,
179 steps: HashMap::new(),
180 in_test_mode,
181 });
182
183 switch_to_next_screen(&mut siv, InstallerStep::Licence, &license_dialog);
184 siv.run();
185 }
186
187 /// Anything that can be done late in the setup and will not result in fatal errors.
188 fn installer_setup_late(siv: &mut Cursive) {
189 let state = siv.user_data::<InstallerState>().cloned().unwrap();
190
191 if !state.in_test_mode {
192 let kmap_id = &state.options.timezone.kb_layout;
193 if let Some(kmap) = state.locales.kmap.get(kmap_id) {
194 if let Err(err) = system::set_keyboard_layout(kmap) {
195 display_setup_warning(siv, &format!("Failed to apply keyboard layout: {err}"));
196 }
197 }
198 }
199
200 if state.runtime_info.total_memory < 1024 {
201 display_setup_warning(
202 siv,
203 concat!(
204 "Less than 1 GiB of usable memory detected, installation will probably fail.\n\n",
205 "See 'System Requirements' in the documentation."
206 ),
207 );
208 }
209
210 if state.setup_info.config.product == ProxmoxProduct::PVE && !state.runtime_info.hvm_supported {
211 display_setup_warning(
212 siv,
213 concat!(
214 "No support for hardware-accelerated KVM virtualization detected.\n\n",
215 "Check BIOS settings for Intel VT / AMD-V / SVM."
216 ),
217 );
218 }
219 }
220
221 fn initial_setup_error(siv: &mut CursiveRunnable, message: &str) -> ! {
222 siv.add_layer(
223 Dialog::around(TextView::new(message))
224 .title("Installer setup error")
225 .button("Ok", Cursive::quit),
226 );
227 siv.run();
228
229 std::process::exit(1);
230 }
231
232 fn display_setup_warning(siv: &mut Cursive, message: &str) {
233 siv.add_layer(Dialog::info(message).title("Warning"));
234 }
235
236 fn switch_to_next_screen(
237 siv: &mut Cursive,
238 step: InstallerStep,
239 constructor: &dyn Fn(&mut Cursive) -> InstallerView,
240 ) {
241 let state = siv.user_data::<InstallerState>().cloned().unwrap();
242 let is_first_screen = state.steps.is_empty();
243
244 // Check if the screen already exists; if yes, then simply switch to it.
245 if let Some(screen_id) = state.steps.get(&step) {
246 siv.set_screen(*screen_id);
247
248 // The summary view cannot be cached (otherwise it would display stale values). Thus
249 // replace it if the screen is switched to.
250 // TODO: Could be done by e.g. having all the main dialog views implement some sort of
251 // .refresh(), which can be called if the view is switched to.
252 if step == InstallerStep::Summary {
253 let view = constructor(siv);
254 siv.screen_mut().pop_layer();
255 siv.screen_mut().add_layer(view);
256 }
257
258 return;
259 }
260
261 let v = constructor(siv);
262 let screen = siv.add_active_screen();
263 siv.with_user_data(|state: &mut InstallerState| state.steps.insert(step, screen));
264
265 siv.screen_mut().add_transparent_layer_at(
266 XY {
267 x: Offset::Parent(0),
268 y: Offset::Parent(0),
269 },
270 InstallerBackgroundView::new(),
271 );
272
273 siv.screen_mut().add_layer(v);
274
275 // If this is the first screen to be added, execute our late setup first.
276 // Needs to be done here, at the end, to ensure that any potential layers get added to
277 // the right screen and are on top.
278 if is_first_screen {
279 installer_setup_late(siv);
280 }
281 }
282
283 fn switch_to_prev_screen(siv: &mut Cursive) {
284 let id = siv.active_screen().saturating_sub(1);
285 siv.set_screen(id);
286 }
287
288 fn prompt_dialog(
289 siv: &mut Cursive,
290 title: &str,
291 text: &str,
292 yes_text: &str,
293 callback_yes: Box<dyn Fn(&mut Cursive)>,
294 no_text: &str,
295 callback_no: Box<dyn Fn(&mut Cursive)>,
296 ) {
297 siv.add_layer(
298 Dialog::around(TextView::new(text))
299 .title(title)
300 .button(no_text, move |siv| {
301 siv.pop_layer();
302 callback_no(siv);
303 })
304 .button(yes_text, move |siv| {
305 siv.pop_layer();
306 callback_yes(siv);
307 }),
308 )
309 }
310
311 fn trigger_abort_install_dialog(siv: &mut Cursive) {
312 #[cfg(debug_assertions)]
313 siv.quit();
314
315 #[cfg(not(debug_assertions))]
316 prompt_dialog(
317 siv,
318 "Abort installation?",
319 "Are you sure you want to abort the installation?",
320 "Yes",
321 Box::new(Cursive::quit),
322 "No",
323 Box::new(|_| {}),
324 )
325 }
326
327 fn abort_install_button() -> Button {
328 Button::new("Abort", trigger_abort_install_dialog)
329 }
330
331 fn get_eula(setup: &SetupInfo) -> String {
332 let mut path = setup.locations.iso.clone();
333 path.push("EULA");
334
335 std::fs::read_to_string(path)
336 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
337 }
338
339 fn license_dialog(siv: &mut Cursive) -> InstallerView {
340 let state = siv.user_data::<InstallerState>().unwrap();
341
342 let mut bbar = LinearLayout::horizontal()
343 .child(abort_install_button())
344 .child(DummyView.full_width())
345 .child(Button::new("I agree", |siv| {
346 switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog)
347 }));
348 let _ = bbar.set_focus_index(2); // ignore errors
349
350 let mut inner = LinearLayout::vertical()
351 .child(PaddedView::lrtb(
352 0,
353 0,
354 1,
355 0,
356 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
357 ))
358 .child(Panel::new(ScrollView::new(TextView::new(get_eula(
359 &state.setup_info,
360 )))))
361 .child(PaddedView::lrtb(1, 1, 1, 0, bbar));
362
363 let _ = inner.set_focus_index(2); // ignore errors
364
365 InstallerView::with_raw(state, inner)
366 }
367
368 fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
369 let state = siv.user_data::<InstallerState>().cloned().unwrap();
370
371 InstallerView::new(
372 &state,
373 BootdiskOptionsView::new(siv, &state.runtime_info, &state.options.bootdisk)
374 .with_name("bootdisk-options"),
375 Box::new(|siv| {
376 let options = siv.call_on_name("bootdisk-options", BootdiskOptionsView::get_values);
377
378 match options {
379 Some(Ok(options)) => {
380 siv.with_user_data(|state: &mut InstallerState| {
381 state.options.bootdisk = options;
382 });
383
384 switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog);
385 }
386
387 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
388 _ => siv.add_layer(Dialog::info("Invalid values")),
389 }
390 }),
391 true,
392 )
393 }
394
395 fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
396 let state = siv.user_data::<InstallerState>().unwrap();
397 let options = &state.options.timezone;
398
399 InstallerView::new(
400 state,
401 TimezoneOptionsView::new(&state.locales, options).with_name("timezone-options"),
402 Box::new(|siv| {
403 let options = siv.call_on_name("timezone-options", TimezoneOptionsView::get_values);
404
405 match options {
406 Some(Ok(options)) => {
407 siv.with_user_data(|state: &mut InstallerState| {
408 state.options.timezone = options;
409 });
410
411 switch_to_next_screen(siv, InstallerStep::Password, &password_dialog);
412 }
413 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
414 _ => siv.add_layer(Dialog::info("Invalid values")),
415 }
416 }),
417 true,
418 )
419 }
420
421 fn password_dialog(siv: &mut Cursive) -> InstallerView {
422 let state = siv.user_data::<InstallerState>().unwrap();
423 let options = &state.options.password;
424
425 let inner = FormView::new()
426 .child("Root password", EditView::new().secret())
427 .child("Confirm root password", EditView::new().secret())
428 .child(
429 "Administrator email",
430 EditView::new().content(&options.email),
431 )
432 .with_name("password-options");
433
434 InstallerView::new(
435 state,
436 inner,
437 Box::new(|siv| {
438 let options = siv.call_on_name("password-options", |view: &mut FormView| {
439 let root_password = view
440 .get_value::<EditView, _>(0)
441 .ok_or("failed to retrieve password")?;
442
443 let confirm_password = view
444 .get_value::<EditView, _>(1)
445 .ok_or("failed to retrieve password confirmation")?;
446
447 let email = view
448 .get_value::<EditView, _>(2)
449 .ok_or("failed to retrieve email")?;
450
451 let email_regex =
452 Regex::new(r"^[\w\+\-\~]+(\.[\w\+\-\~]+)*@[a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)*$")
453 .unwrap();
454
455 if root_password.len() < 5 {
456 Err("password too short, must be at least 5 characters long")
457 } else if root_password != confirm_password {
458 Err("passwords do not match")
459 } else if email == "mail@example.invalid" {
460 Err("invalid email address")
461 } else if !email_regex.is_match(&email) {
462 Err("Email does not look like a valid address (user@domain.tld)")
463 } else {
464 Ok(PasswordOptions {
465 root_password,
466 email,
467 })
468 }
469 });
470
471 match options {
472 Some(Ok(options)) => {
473 siv.with_user_data(|state: &mut InstallerState| {
474 state.options.password = options;
475 });
476
477 switch_to_next_screen(siv, InstallerStep::Network, &network_dialog);
478 }
479 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
480 _ => siv.add_layer(Dialog::info("Invalid values")),
481 }
482 }),
483 false,
484 )
485 }
486
487 fn network_dialog(siv: &mut Cursive) -> InstallerView {
488 let state = siv.user_data::<InstallerState>().unwrap();
489 let options = &state.options.network;
490 let ifaces = state.runtime_info.network.interfaces.values();
491 let ifnames = ifaces
492 .clone()
493 .map(|iface| (iface.render(), iface.name.clone()));
494 let mut ifaces_selection = SelectView::new().popup().with_all(ifnames.clone());
495
496 // sort first to always have stable view
497 ifaces_selection.sort();
498 let selected = ifaces_selection
499 .iter()
500 .position(|(_label, iface)| *iface == options.ifname)
501 .unwrap_or(ifaces.len() - 1);
502
503 ifaces_selection.set_selection(selected);
504
505 let inner = FormView::new()
506 .child("Management interface", ifaces_selection)
507 .child(
508 "Hostname (FQDN)",
509 EditView::new().content(options.fqdn.to_string()),
510 )
511 .child(
512 "IP address (CIDR)",
513 CidrAddressEditView::new().content(options.address.clone()),
514 )
515 .child(
516 "Gateway address",
517 EditView::new().content(options.gateway.to_string()),
518 )
519 .child(
520 "DNS server address",
521 EditView::new().content(options.dns_server.to_string()),
522 )
523 .with_name("network-options");
524
525 InstallerView::new(
526 state,
527 inner,
528 Box::new(|siv| {
529 let options = siv.call_on_name("network-options", |view: &mut FormView| {
530 let ifname = view
531 .get_value::<SelectView, _>(0)
532 .ok_or("failed to retrieve management interface name")?;
533
534 let fqdn = view
535 .get_value::<EditView, _>(1)
536 .ok_or("failed to retrieve host FQDN")?
537 .parse::<Fqdn>()
538 .map_err(|err| format!("hostname does not look valid:\n\n{err}"))?;
539
540 let address = view
541 .get_value::<CidrAddressEditView, _>(2)
542 .ok_or("failed to retrieve host address")?;
543
544 let gateway = view
545 .get_value::<EditView, _>(3)
546 .ok_or("failed to retrieve gateway address")?
547 .parse::<IpAddr>()
548 .map_err(|err| err.to_string())?;
549
550 let dns_server = view
551 .get_value::<EditView, _>(4)
552 .ok_or("failed to retrieve DNS server address")?
553 .parse::<IpAddr>()
554 .map_err(|err| err.to_string())?;
555
556 if address.addr().is_ipv4() != gateway.is_ipv4() {
557 Err("host and gateway IP address version must not differ".to_owned())
558 } else if address.addr().is_ipv4() != dns_server.is_ipv4() {
559 Err("host and DNS IP address version must not differ".to_owned())
560 } else if fqdn.to_string().ends_with(".invalid") {
561 Err("hostname does not look valid".to_owned())
562 } else {
563 Ok(NetworkOptions {
564 ifname,
565 fqdn,
566 address,
567 gateway,
568 dns_server,
569 })
570 }
571 });
572
573 match options {
574 Some(Ok(options)) => {
575 siv.with_user_data(|state: &mut InstallerState| {
576 state.options.network = options;
577 });
578
579 switch_to_next_screen(siv, InstallerStep::Summary, &summary_dialog);
580 }
581 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
582 _ => siv.add_layer(Dialog::info("Invalid values")),
583 }
584 }),
585 true,
586 )
587 }
588
589 pub struct SummaryOption {
590 name: &'static str,
591 value: String,
592 }
593
594 impl SummaryOption {
595 pub fn new<S: Into<String>>(name: &'static str, value: S) -> Self {
596 Self {
597 name,
598 value: value.into(),
599 }
600 }
601 }
602
603 impl TableViewItem for SummaryOption {
604 fn get_column(&self, name: &str) -> String {
605 match name {
606 "name" => self.name.to_owned(),
607 "value" => self.value.clone(),
608 _ => unreachable!(),
609 }
610 }
611 }
612
613 fn summary_dialog(siv: &mut Cursive) -> InstallerView {
614 let state = siv.user_data::<InstallerState>().unwrap();
615
616 let mut bbar = LinearLayout::horizontal()
617 .child(abort_install_button())
618 .child(DummyView.full_width())
619 .child(Button::new("Previous", switch_to_prev_screen))
620 .child(DummyView)
621 .child(Button::new("Install", |siv| {
622 let autoreboot = siv
623 .find_name("reboot-after-install")
624 .map(|v: ViewRef<Checkbox>| v.is_checked())
625 .unwrap_or_default();
626
627 siv.with_user_data(|state: &mut InstallerState| {
628 state.options.autoreboot = autoreboot;
629 });
630
631 switch_to_next_screen(siv, InstallerStep::Install, &install_progress_dialog);
632 }));
633
634 let _ = bbar.set_focus_index(2); // ignore errors
635
636 let mut inner = LinearLayout::vertical()
637 .child(PaddedView::lrtb(
638 0,
639 0,
640 1,
641 2,
642 TableView::new()
643 .columns(&[
644 ("name".to_owned(), "Option".to_owned()),
645 ("value".to_owned(), "Selected value".to_owned()),
646 ])
647 .items(state.options.to_summary(&state.locales)),
648 ))
649 .child(
650 LinearLayout::horizontal()
651 .child(DummyView.full_width())
652 .child(Checkbox::new().checked().with_name("reboot-after-install"))
653 .child(
654 TextView::new(" Automatically reboot after successful installation").no_wrap(),
655 )
656 .child(DummyView.full_width()),
657 )
658 .child(PaddedView::lrtb(1, 1, 1, 0, bbar));
659
660 let _ = inner.set_focus_index(2); // ignore errors
661
662 InstallerView::with_raw(state, inner)
663 }
664
665 fn install_progress_dialog(siv: &mut Cursive) -> InstallerView {
666 // Ensure the screen is updated independently of keyboard events and such
667 siv.set_autorefresh(true);
668
669 let state = siv.user_data::<InstallerState>().cloned().unwrap();
670 InstallerView::with_raw(&state, InstallProgressView::new(siv))
671 }