]> git.proxmox.com Git - pve-installer.git/blob - proxmox-tui-installer/src/main.rs
tui: add some more field validation checks
[pve-installer.git] / proxmox-tui-installer / src / main.rs
1 #![forbid(unsafe_code)]
2
3 mod options;
4 mod utils;
5 mod views;
6
7 use crate::options::*;
8 use cursive::{
9 event::Event,
10 view::{Nameable, Resizable, ViewWrapper},
11 views::{
12 Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, PaddedView, Panel,
13 ResizedView, ScrollView, SelectView, TextView,
14 },
15 Cursive, View,
16 };
17 use std::net::IpAddr;
18 use views::{BootdiskOptionsView, CidrAddressEditView, FormView, TableView, TableViewItem};
19
20 // TextView::center() seems to garble the first two lines, so fix it manually here.
21 const LOGO: &str = r#"
22 ____ _ __ _____
23 / __ \_________ _ ______ ___ ____ _ __ | | / / ____/
24 / /_/ / ___/ __ \| |/_/ __ `__ \/ __ \| |/_/ | | / / __/
25 / ____/ / / /_/ /> </ / / / / / /_/ /> < | |/ / /___
26 /_/ /_/ \____/_/|_/_/ /_/ /_/\____/_/|_| |___/_____/
27 "#;
28
29 const TITLE: &str = "Proxmox VE Installer";
30
31 struct InstallerView {
32 view: ResizedView<LinearLayout>,
33 }
34
35 impl InstallerView {
36 pub fn new<T: View>(view: T, next_cb: Box<dyn Fn(&mut Cursive)>) -> Self {
37 let inner = LinearLayout::vertical()
38 .child(PaddedView::lrtb(0, 0, 1, 1, view))
39 .child(PaddedView::lrtb(
40 1,
41 1,
42 0,
43 0,
44 LinearLayout::horizontal()
45 .child(abort_install_button())
46 .child(DummyView.full_width())
47 .child(Button::new("Previous", switch_to_prev_screen))
48 .child(DummyView)
49 .child(Button::new("Next", next_cb)),
50 ));
51
52 Self::with_raw(inner)
53 }
54
55 pub fn with_raw<T: View>(view: T) -> Self {
56 let inner = LinearLayout::vertical()
57 .child(PaddedView::lrtb(1, 1, 0, 1, TextView::new(LOGO).center()))
58 .child(Dialog::around(view).title(TITLE));
59
60 Self {
61 // Limit the maximum to something reasonable, such that it won't get spread out much
62 // depending on the screen.
63 view: ResizedView::with_max_size((120, 40), inner),
64 }
65 }
66 }
67
68 impl ViewWrapper for InstallerView {
69 cursive::wrap_impl!(self.view: ResizedView<LinearLayout>);
70 }
71
72 #[derive(Clone)]
73 struct InstallerData {
74 options: InstallerOptions,
75 available_disks: Vec<Disk>,
76 }
77
78 fn main() {
79 let mut siv = cursive::termion();
80
81 siv.clear_global_callbacks(Event::CtrlChar('c'));
82 siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
83
84 // TODO: retrieve actual disk info
85 let available_disks = vec![Disk {
86 path: "/dev/vda".to_owned(),
87 size: 17179869184,
88 }];
89
90 siv.set_user_data(InstallerData {
91 options: InstallerOptions {
92 bootdisk: BootdiskOptions::defaults_from(&available_disks[0]),
93 timezone: TimezoneOptions::default(),
94 password: PasswordOptions::default(),
95 network: NetworkOptions::default(),
96 },
97 available_disks,
98 });
99
100 add_next_screen(&mut siv, &license_dialog);
101 siv.run();
102 }
103
104 fn add_next_screen(siv: &mut Cursive, constructor: &dyn Fn(&mut Cursive) -> InstallerView) {
105 let v = constructor(siv);
106 siv.add_active_screen();
107 siv.screen_mut().add_layer(v);
108 }
109
110 fn switch_to_prev_screen(siv: &mut Cursive) {
111 let id = siv.active_screen().saturating_sub(1);
112 siv.set_screen(id);
113 }
114
115 #[cfg(not(debug_assertions))]
116 fn yes_no_dialog(
117 siv: &mut Cursive,
118 title: &str,
119 text: &str,
120 callback: &'static dyn Fn(&mut Cursive),
121 ) {
122 siv.add_layer(
123 Dialog::around(TextView::new(text))
124 .title(title)
125 .dismiss_button("No")
126 .button("Yes", callback),
127 )
128 }
129
130 fn trigger_abort_install_dialog(siv: &mut Cursive) {
131 #[cfg(debug_assertions)]
132 siv.quit();
133
134 #[cfg(not(debug_assertions))]
135 yes_no_dialog(
136 siv,
137 "Abort installation?",
138 "Are you sure you want to abort the installation?",
139 &Cursive::quit,
140 )
141 }
142
143 fn abort_install_button() -> Button {
144 Button::new("Abort", trigger_abort_install_dialog)
145 }
146
147 fn get_eula() -> String {
148 // TODO: properly using info from Proxmox::Install::Env::setup()
149 std::fs::read_to_string("/cdrom/EULA")
150 .unwrap_or_else(|_| "< Debug build - ignoring non-existing EULA >".to_owned())
151 }
152
153 fn license_dialog(_: &mut Cursive) -> InstallerView {
154 let inner = LinearLayout::vertical()
155 .child(PaddedView::lrtb(
156 0,
157 0,
158 1,
159 0,
160 TextView::new("END USER LICENSE AGREEMENT (EULA)").center(),
161 ))
162 .child(Panel::new(ScrollView::new(
163 TextView::new(get_eula()).center(),
164 )))
165 .child(PaddedView::lrtb(
166 1,
167 1,
168 1,
169 0,
170 LinearLayout::horizontal()
171 .child(abort_install_button())
172 .child(DummyView.full_width())
173 .child(Button::new("I agree", |siv| {
174 add_next_screen(siv, &bootdisk_dialog)
175 })),
176 ));
177
178 InstallerView::with_raw(inner)
179 }
180
181 fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
182 let data = siv.user_data::<InstallerData>().cloned().unwrap();
183
184 InstallerView::new(
185 BootdiskOptionsView::new(&data.available_disks, &data.options.bootdisk)
186 .with_name("bootdisk-options"),
187 Box::new(|siv| {
188 let options = siv
189 .call_on_name("bootdisk-options", BootdiskOptionsView::get_values)
190 .flatten();
191
192 if let Some(options) = options {
193 siv.with_user_data(|data: &mut InstallerData| {
194 data.options.bootdisk = options;
195 });
196
197 add_next_screen(siv, &timezone_dialog);
198 } else {
199 siv.add_layer(Dialog::info("Invalid values"));
200 }
201 }),
202 )
203 }
204
205 fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
206 let options = siv
207 .user_data::<InstallerData>()
208 .map(|data| data.options.timezone.clone())
209 .unwrap_or_default();
210
211 let inner = FormView::new()
212 .child("Country", EditView::new().content("Austria"))
213 .child("Timezone", EditView::new().content(options.timezone))
214 .child(
215 "Keyboard layout",
216 EditView::new().content(options.kb_layout),
217 )
218 .with_name("timezone-options");
219
220 InstallerView::new(
221 inner,
222 Box::new(|siv| {
223 let options: Option<Result<TimezoneOptions, String>> =
224 siv.call_on_name("timezone-options", |view: &mut FormView| {
225 let timezone = view
226 .get_value::<EditView, _>(1)
227 .ok_or("failed to retrieve timezone")?;
228
229 let kb_layout = view
230 .get_value::<EditView, _>(2)
231 .ok_or("failed to retrieve keyboard layout")?;
232
233 Ok(TimezoneOptions {
234 timezone,
235 kb_layout,
236 })
237 });
238
239 match options {
240 Some(Ok(options)) => {
241 siv.with_user_data(|data: &mut InstallerData| {
242 data.options.timezone = options;
243 });
244
245 add_next_screen(siv, &password_dialog);
246 }
247 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
248 _ => siv.add_layer(Dialog::info("Invalid values")),
249 }
250 }),
251 )
252 }
253
254 fn password_dialog(siv: &mut Cursive) -> InstallerView {
255 let options = siv
256 .user_data::<InstallerData>()
257 .map(|data| data.options.password.clone())
258 .unwrap_or_default();
259
260 let inner = FormView::new()
261 .child("Root password", EditView::new().secret())
262 .child("Confirm root password", EditView::new().secret())
263 .child("Administator email", EditView::new().content(options.email))
264 .with_name("password-options");
265
266 InstallerView::new(
267 inner,
268 Box::new(|siv| {
269 let options = siv.call_on_name("password-options", |view: &mut FormView| {
270 let root_password = view
271 .get_value::<EditView, _>(0)
272 .ok_or("failed to retrieve password")?;
273
274 let confirm_password = view
275 .get_value::<EditView, _>(1)
276 .ok_or("failed to retrieve password confirmation")?;
277
278 let email = view
279 .get_value::<EditView, _>(2)
280 .ok_or("failed to retrieve email")?;
281
282 if root_password.len() < 5 {
283 Err("password too short")
284 } else if root_password != confirm_password {
285 Err("passwords do not match")
286 } else if email.ends_with(".invalid") {
287 Err("invalid email address")
288 } else {
289 Ok(PasswordOptions {
290 root_password,
291 email,
292 })
293 }
294 });
295
296 match options {
297 Some(Ok(options)) => {
298 siv.with_user_data(|data: &mut InstallerData| {
299 data.options.password = options;
300 });
301
302 add_next_screen(siv, &network_dialog);
303 }
304 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
305 _ => siv.add_layer(Dialog::info("Invalid values")),
306 }
307 }),
308 )
309 }
310
311 fn network_dialog(siv: &mut Cursive) -> InstallerView {
312 let options = siv
313 .user_data::<InstallerData>()
314 .map(|data| data.options.network.clone())
315 .unwrap_or_default();
316
317 let inner = FormView::new()
318 .child(
319 "Management interface",
320 SelectView::new().popup().with_all_str(vec!["eth0"]),
321 )
322 .child("Hostname (FQDN)", EditView::new().content(options.fqdn))
323 .child(
324 "IP address (CIDR)",
325 CidrAddressEditView::new().content(options.address),
326 )
327 .child(
328 "Gateway address",
329 EditView::new().content(options.gateway.to_string()),
330 )
331 .child(
332 "DNS server address",
333 EditView::new().content(options.dns_server.to_string()),
334 )
335 .with_name("network-options");
336
337 InstallerView::new(
338 inner,
339 Box::new(|siv| {
340 let options = siv.call_on_name("network-options", |view: &mut FormView| {
341 let ifname = view
342 .get_value::<SelectView, _>(0)
343 .ok_or("failed to retrieve management interface name")?;
344
345 let fqdn = view
346 .get_value::<EditView, _>(1)
347 .ok_or("failed to retrieve host FQDN")?;
348
349 let address = view
350 .get_value::<CidrAddressEditView, _>(2)
351 .ok_or("failed to retrieve host address")?;
352
353 let gateway = view
354 .get_value::<EditView, _>(3)
355 .ok_or("failed to retrieve gateway address")?
356 .parse::<IpAddr>()
357 .map_err(|err| err.to_string())?;
358
359 let dns_server = view
360 .get_value::<EditView, _>(3)
361 .ok_or("failed to retrieve DNS server address")?
362 .parse::<IpAddr>()
363 .map_err(|err| err.to_string())?;
364
365 if address.addr().is_ipv4() != gateway.is_ipv4() {
366 Err("host and gateway IP address version must not differ".to_owned())
367 } else if address.addr().is_ipv4() != dns_server.is_ipv4() {
368 Err("host and DNS IP address version must not differ".to_owned())
369 } else if fqdn.chars().all(|c| c.is_ascii_digit()) {
370 // Not supported/allowed on Debian
371 Err("hostname cannot be purely numeric".to_owned())
372 } else {
373 Ok(NetworkOptions {
374 ifname,
375 fqdn,
376 address,
377 gateway,
378 dns_server,
379 })
380 }
381 });
382
383 match options {
384 Some(Ok(options)) => {
385 siv.with_user_data(|data: &mut InstallerData| {
386 data.options.network = options;
387 });
388
389 add_next_screen(siv, &summary_dialog);
390 }
391 Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
392 _ => siv.add_layer(Dialog::info("Invalid values")),
393 }
394 }),
395 )
396 }
397
398 pub struct SummaryOption {
399 name: &'static str,
400 value: String,
401 }
402
403 impl SummaryOption {
404 pub fn new<S: Into<String>>(name: &'static str, value: S) -> Self {
405 Self {
406 name,
407 value: value.into(),
408 }
409 }
410 }
411
412 impl TableViewItem for SummaryOption {
413 fn get_column(&self, name: &str) -> String {
414 match name {
415 "name" => self.name.to_owned(),
416 "value" => self.value.clone(),
417 _ => unreachable!(),
418 }
419 }
420 }
421
422 fn summary_dialog(siv: &mut Cursive) -> InstallerView {
423 let options = siv
424 .user_data::<InstallerData>()
425 .map(|d| d.options.clone())
426 .unwrap();
427
428 let inner = LinearLayout::vertical()
429 .child(PaddedView::lrtb(
430 0,
431 0,
432 1,
433 2,
434 TableView::new()
435 .columns(&[
436 ("name".to_owned(), "Option".to_owned()),
437 ("value".to_owned(), "Selected value".to_owned()),
438 ])
439 .items(options.to_summary()),
440 ))
441 .child(
442 LinearLayout::horizontal()
443 .child(DummyView.full_width())
444 .child(Checkbox::new().with_name("reboot-after-install"))
445 .child(
446 TextView::new(" Automatically reboot after successful installation").no_wrap(),
447 )
448 .child(DummyView.full_width()),
449 )
450 .child(PaddedView::lrtb(
451 1,
452 1,
453 1,
454 0,
455 LinearLayout::horizontal()
456 .child(abort_install_button())
457 .child(DummyView.full_width())
458 .child(Button::new("Previous", switch_to_prev_screen))
459 .child(DummyView)
460 .child(Button::new("Install", |_| {})),
461 ));
462
463 InstallerView::with_raw(inner)
464 }