innernet_shared/
prompts.rs

1use crate::{
2    interface_config::{InterfaceConfig, InterfaceInfo, ServerInfo},
3    AddCidrOpts, AddDeleteAssociationOpts, AddPeerOpts, Association, Cidr, CidrContents, CidrTree,
4    DeleteCidrOpts, EnableDisablePeerOpts, Endpoint, Error, Hostname, IpNetExt, ListenPortOpts,
5    Peer, PeerContents, RenameCidrOpts, RenamePeerOpts, PERSISTENT_KEEPALIVE_INTERVAL_SECS,
6};
7use anyhow::anyhow;
8use colored::*;
9use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
10use innernet_publicip::Preference;
11use ipnet::IpNet;
12use once_cell::sync::Lazy;
13use std::{
14    fmt::{Debug, Display},
15    fs::{File, OpenOptions},
16    io,
17    net::{IpAddr, Ipv4Addr, SocketAddr},
18    str::FromStr,
19    time::SystemTime,
20};
21use wireguard_control::{InterfaceName, KeyPair};
22
23pub static THEME: Lazy<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
24
25pub fn ensure_interactive(prompt: &str) -> Result<(), io::Error> {
26    if atty::is(atty::Stream::Stdin) {
27        Ok(())
28    } else {
29        Err(io::Error::new(
30            io::ErrorKind::BrokenPipe,
31            format!("Prompt \"{prompt}\" failed because TTY isn't connected."),
32        ))
33    }
34}
35
36pub fn confirm(prompt: &str) -> Result<bool, dialoguer::Error> {
37    ensure_interactive(prompt)?;
38    Confirm::with_theme(&*THEME)
39        .wait_for_newline(true)
40        .with_prompt(prompt)
41        .default(false)
42        .interact()
43}
44
45pub fn select<'a, T: ToString>(
46    prompt: &str,
47    items: &'a [T],
48) -> Result<(usize, &'a T), dialoguer::Error> {
49    ensure_interactive(prompt)?;
50    let choice = Select::with_theme(&*THEME)
51        .with_prompt(prompt)
52        .items(items)
53        .interact()?;
54    Ok((choice, &items[choice]))
55}
56
57pub enum Prefill<T> {
58    Default(T),
59    Editable(String),
60    None,
61}
62
63pub fn input<T>(prompt: &str, prefill: Prefill<T>) -> Result<T, dialoguer::Error>
64where
65    T: Clone + FromStr + Display,
66    T::Err: Display + Debug,
67{
68    ensure_interactive(prompt)?;
69    let input = Input::with_theme(&*THEME);
70    match prefill {
71        Prefill::Default(value) => input.default(value),
72        Prefill::Editable(value) => input.with_initial_text(value),
73        _ => input,
74    }
75    .with_prompt(prompt)
76    // TODO(mbernat): workaround for https://github.com/console-rs/dialoguer/issues/330
77    .interact_text()
78}
79
80/// Bring up a prompt to create a new CIDR. Returns the peer request.
81pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrContents>, Error> {
82    let parent_cidr = if let Some(ref parent_name) = request.parent {
83        cidrs
84            .iter()
85            .find(|cidr| &cidr.name == parent_name)
86            .ok_or_else(|| anyhow!("No parent CIDR with that name exists."))?
87    } else {
88        choose_cidr(cidrs, "Parent CIDR")?
89    };
90
91    let name = if let Some(ref name) = request.name {
92        name.clone()
93    } else {
94        input("Name", Prefill::None)?
95    };
96
97    let cidr = if let Some(cidr) = request.cidr {
98        cidr
99    } else {
100        input("CIDR", Prefill::None)?
101    };
102
103    let cidr_request = CidrContents {
104        name: name.to_string(),
105        cidr,
106        parent: Some(parent_cidr.id),
107    };
108
109    Ok(
110        if request.yes || confirm(&format!("Create CIDR \"{}\"?", cidr_request.name))? {
111            Some(cidr_request)
112        } else {
113            None
114        },
115    )
116}
117
118/// Bring up a prompt to rename an existing CIDR. Returns the CIDR request.
119pub fn rename_cidr(
120    cidrs: &[Cidr],
121    args: &RenameCidrOpts,
122) -> Result<Option<(CidrContents, String)>, Error> {
123    let old_cidr = if let Some(ref name) = args.name {
124        cidrs
125            .iter()
126            .find(|c| &c.name == name)
127            .ok_or_else(|| anyhow!("CIDR '{}' does not exist", name))?
128            .clone()
129    } else {
130        let (cidr_index, _) = select(
131            "CIDR to rename",
132            &cidrs.iter().map(|ep| ep.name.clone()).collect::<Vec<_>>(),
133        )?;
134        cidrs[cidr_index].clone()
135    };
136    let old_name = old_cidr.name.clone();
137    let new_name = if let Some(ref name) = args.new_name {
138        name.clone()
139    } else {
140        input("New Name", Prefill::None)?
141    };
142
143    let mut new_cidr = old_cidr;
144    new_cidr.contents.name.clone_from(&new_name);
145
146    Ok(
147        if args.yes
148            || confirm(&format!(
149                "Rename CIDR {} to {}?",
150                old_name.yellow(),
151                new_name.yellow()
152            ))?
153        {
154            Some((new_cidr.contents, old_name))
155        } else {
156            None
157        },
158    )
159}
160
161/// Bring up a prompt to delete a CIDR. Returns the peer request.
162pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) -> Result<i64, Error> {
163    let eligible_cidrs: Vec<_> = cidrs
164        .iter()
165        .filter(|cidr| {
166            !peers.iter().any(|peer| peer.contents.cidr_id == cidr.id) &&
167            !cidrs.iter().any(
168                |cidr2| matches!(cidr2.contents.parent, Some(parent_id) if parent_id == cidr.id)
169            )
170        })
171        .collect();
172    let cidr = if let Some(ref name) = request.name {
173        cidrs
174            .iter()
175            .find(|cidr| &cidr.name == name)
176            .ok_or_else(|| anyhow!("CIDR {} doesn't exist or isn't eligible for deletion", name))?
177    } else {
178        select("Delete CIDR", &eligible_cidrs)?.1
179    };
180
181    if request.yes || confirm(&format!("Delete CIDR \"{}\"?", cidr.name))? {
182        Ok(cidr.id)
183    } else {
184        Err(anyhow!("Canceled"))
185    }
186}
187
188pub fn choose_cidr<'a>(cidrs: &'a [Cidr], text: &'static str) -> Result<&'a Cidr, Error> {
189    let eligible_cidrs: Vec<_> = cidrs
190        .iter()
191        .filter(|cidr| cidr.name != "innernet-server")
192        .collect();
193    Ok(select(text, &eligible_cidrs)?.1)
194}
195
196pub fn choose_association<'a>(
197    associations: &'a [Association],
198    cidrs: &'a [Cidr],
199    args: &AddDeleteAssociationOpts,
200) -> Result<&'a Association, Error> {
201    match (&args.cidr1, &args.cidr2) {
202        (Some(cidr1_name), Some(cidr2_name)) => {
203            let cidr1 = find_cidr(cidrs, cidr1_name)?;
204            let cidr2 = find_cidr(cidrs, cidr2_name)?;
205            associations
206                .iter()
207                .find(|association| {
208                    (association.cidr_id_1 == cidr1.id && association.cidr_id_2 == cidr2.id)
209                        || (association.cidr_id_1 == cidr2.id && association.cidr_id_2 == cidr1.id)
210                })
211                .ok_or_else(|| anyhow!("CIDR association does not exist"))
212        },
213        _ => {
214            let names: Vec<_> = associations
215                .iter()
216                .map(|association| {
217                    format!(
218                        "{}: {} <=> {}",
219                        association.id,
220                        &cidrs
221                            .iter()
222                            .find(|c| c.id == association.cidr_id_1)
223                            .unwrap()
224                            .name,
225                        &cidrs
226                            .iter()
227                            .find(|c| c.id == association.cidr_id_2)
228                            .unwrap()
229                            .name
230                    )
231                })
232                .collect();
233            let (index, _) = select("Association", &names)?;
234
235            Ok(&associations[index])
236        },
237    }
238}
239
240fn find_cidr<'a>(cidrs: &'a [Cidr], name: &str) -> Result<&'a Cidr, Error> {
241    cidrs
242        .iter()
243        .find(|c| c.name == name)
244        .ok_or_else(|| anyhow!("can't find cidr '{}'", name))
245}
246
247fn find_or_prompt_cidr<'a>(
248    cidrs: &'a [Cidr],
249    sub_opt: &Option<String>,
250    prompt: &'static str,
251) -> Result<&'a Cidr, Error> {
252    if let Some(name) = sub_opt {
253        find_cidr(cidrs, name)
254    } else {
255        choose_cidr(cidrs, prompt)
256    }
257}
258
259pub fn add_association<'a>(
260    cidrs: &'a [Cidr],
261    args: &AddDeleteAssociationOpts,
262) -> Result<Option<(&'a Cidr, &'a Cidr)>, Error> {
263    let cidr1 = find_or_prompt_cidr(cidrs, &args.cidr1, "First CIDR")?;
264    let cidr2 = find_or_prompt_cidr(cidrs, &args.cidr2, "Second CIDR")?;
265
266    Ok(
267        if args.yes
268            || confirm(&format!(
269                "Add association: {} <=> {}?",
270                cidr1.name.yellow().bold(),
271                cidr2.name.yellow().bold()
272            ))?
273        {
274            Some((cidr1, cidr2))
275        } else {
276            None
277        },
278    )
279}
280
281pub fn delete_association<'a>(
282    associations: &'a [Association],
283    cidrs: &'a [Cidr],
284    args: &AddDeleteAssociationOpts,
285) -> Result<Option<&'a Association>, Error> {
286    let association = choose_association(associations, cidrs, args)?;
287
288    Ok(
289        if args.yes || confirm(&format!("Delete association #{}?", association.id))? {
290            Some(association)
291        } else {
292            None
293        },
294    )
295}
296
297/// Bring up a prompt to create a new peer. Returns the peer request.
298pub fn add_peer(
299    peers: &[Peer],
300    cidr_tree: &CidrTree,
301    args: &AddPeerOpts,
302) -> Result<Option<(PeerContents, KeyPair, String, File)>, Error> {
303    let leaves = cidr_tree.leaves();
304
305    let cidr = if let Some(ref parent_name) = args.cidr {
306        leaves
307            .iter()
308            .find(|cidr| &cidr.name == parent_name)
309            .ok_or_else(|| anyhow!("No eligible CIDR with that name exists."))?
310    } else {
311        choose_cidr(&leaves[..], "Eligible CIDRs for peer")?
312    };
313
314    let mut available_ip = None;
315    let candidate_ips = cidr.hosts().filter(|ip| cidr.is_assignable(ip));
316    for ip in candidate_ips {
317        if !peers.iter().any(|peer| peer.ip == ip) {
318            available_ip = Some(ip);
319            break;
320        }
321    }
322
323    let available_ip = available_ip.expect("No IPs in this CIDR are avavilable");
324
325    let ip = if let Some(ip) = args.ip {
326        ip
327    } else if args.auto_ip {
328        available_ip
329    } else {
330        input("IP", Prefill::Default(available_ip))?
331    };
332
333    let name = if let Some(ref name) = args.name {
334        name.clone()
335    } else {
336        input("Name", Prefill::None)?
337    };
338
339    let is_admin = if let Some(is_admin) = args.admin {
340        is_admin
341    } else {
342        confirm(&format!("Make {name} an admin?"))?
343    };
344
345    let invite_expires = if let Some(ref invite_expires) = args.invite_expires {
346        invite_expires.clone()
347    } else {
348        input(
349            "Invite expires after",
350            Prefill::Default("14d".parse().map_err(|s: &str| anyhow!(s))?),
351        )?
352    };
353
354    let invite_save_path = if let Some(ref location) = args.save_config {
355        location.clone()
356    } else {
357        input(
358            "Save peer invitation file to",
359            Prefill::Default(format!("{name}.toml")),
360        )?
361    };
362
363    let default_keypair = KeyPair::generate();
364    let peer_request = PeerContents {
365        name,
366        ip,
367        cidr_id: cidr.id,
368        public_key: default_keypair.public.to_base64(),
369        endpoint: None,
370        is_admin,
371        is_disabled: false,
372        is_redeemed: false,
373        persistent_keepalive_interval: Some(PERSISTENT_KEEPALIVE_INTERVAL_SECS),
374        invite_expires: Some(SystemTime::now() + invite_expires.into()),
375        candidates: vec![],
376    };
377
378    Ok(
379        if args.yes || confirm(&format!("Create peer {}?", peer_request.name.yellow()))? {
380            let invite_file = OpenOptions::new()
381                .read(true)
382                .write(true)
383                .create_new(true)
384                .open(&invite_save_path)?;
385            Some((peer_request, default_keypair, invite_save_path, invite_file))
386        } else {
387            None
388        },
389    )
390}
391
392/// Bring up a prompt to rename an existing peer. Returns the peer request.
393pub fn rename_peer(
394    peers: &[Peer],
395    args: &RenamePeerOpts,
396) -> Result<Option<(PeerContents, Hostname)>, Error> {
397    let eligible_peers = peers
398        .iter()
399        .filter(|p| &*p.name != "innernet-server")
400        .collect::<Vec<_>>();
401    let old_peer = if let Some(ref name) = args.name {
402        eligible_peers
403            .into_iter()
404            .find(|p| &p.name == name)
405            .ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
406            .clone()
407    } else {
408        let (peer_index, _) = select(
409            "Peer to rename",
410            &eligible_peers
411                .iter()
412                .map(|ep| ep.name.clone())
413                .collect::<Vec<_>>(),
414        )?;
415        eligible_peers[peer_index].clone()
416    };
417    let old_name = old_peer.name.clone();
418    let new_name = if let Some(ref name) = args.new_name {
419        name.clone()
420    } else {
421        input("New Name", Prefill::None)?
422    };
423
424    let mut new_peer = old_peer;
425    new_peer.contents.name = new_name.clone();
426
427    Ok(
428        if args.yes
429            || confirm(&format!(
430                "Rename peer {} to {}?",
431                old_name.yellow(),
432                new_name.yellow()
433            ))?
434        {
435            Some((new_peer.contents, old_name))
436        } else {
437            None
438        },
439    )
440}
441
442/// Presents a selection and confirmation of eligible peers for either disabling or enabling,
443/// and returns back the ID of the selected peer.
444pub fn enable_or_disable_peer(
445    peers: &[Peer],
446    args: &EnableDisablePeerOpts,
447    enable: bool,
448) -> Result<Option<Peer>, Error> {
449    let enabled_peers: Vec<_> = peers
450        .iter()
451        .filter(|peer| enable && peer.is_disabled || !enable && !peer.is_disabled)
452        .collect();
453
454    let peer = if let Some(ref name) = args.name {
455        enabled_peers
456            .into_iter()
457            .find(|p| &p.name == name)
458            .ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
459    } else {
460        let peer_selection: Vec<_> = enabled_peers
461            .iter()
462            .map(|peer| format!("{} ({})", &peer.name, &peer.ip))
463            .collect();
464        let (index, _) = select(
465            &format!("Peer to {}able", if enable { "en" } else { "dis" }),
466            &peer_selection,
467        )?;
468        enabled_peers[index]
469    };
470
471    Ok(
472        if args.yes
473            || confirm(&format!(
474                "{}able peer {}?",
475                if enable { "En" } else { "Dis" },
476                peer.name.yellow()
477            ))?
478        {
479            Some(peer.clone())
480        } else {
481            None
482        },
483    )
484}
485
486/// Confirm and write a innernet invitation file after a peer has been created.
487pub fn write_peer_invitation(
488    target_file: (&mut File, &str),
489    network_name: &InterfaceName,
490    peer: &Peer,
491    server_peer: &Peer,
492    root_cidr: &Cidr,
493    keypair: KeyPair,
494    server_api_addr: &SocketAddr,
495) -> Result<(), Error> {
496    let peer_invitation = InterfaceConfig {
497        interface: InterfaceInfo {
498            network_name: network_name.to_string(),
499            private_key: keypair.private.to_base64(),
500            address: IpNet::new(peer.ip, root_cidr.prefix_len())?,
501            listen_port: None,
502        },
503        server: ServerInfo {
504            external_endpoint: server_peer
505                .endpoint
506                .clone()
507                .expect("The innernet server should have a WireGuard endpoint"),
508            internal_endpoint: *server_api_addr,
509            public_key: server_peer.public_key.clone(),
510        },
511    };
512
513    peer_invitation.write_to(target_file.0, true, None)?;
514
515    println!(
516        "\nPeer \"{}\" added\n\
517         Peer invitation file written to {}\n\
518         Please send it to them securely (eg. via magic-wormhole) \
519         to bootstrap them onto the network.",
520        peer.name.bold(),
521        target_file.1.bold()
522    );
523
524    Ok(())
525}
526
527pub fn set_listen_port(
528    interface: &InterfaceInfo,
529    args: ListenPortOpts,
530) -> Result<Option<Option<u16>>, Error> {
531    let listen_port = if let Some(listen_port) = args.listen_port {
532        Some(listen_port)
533    } else if !args.unset {
534        Some(input(
535            "Listen port",
536            Prefill::Default(interface.listen_port.unwrap_or(51820)),
537        )?)
538    } else {
539        None
540    };
541
542    let confirmation = Confirm::with_theme(&*THEME)
543        .wait_for_newline(true)
544        .with_prompt(
545            &(if let Some(port) = &listen_port {
546                format!("Set listen port to {port}?")
547            } else {
548                "Unset and randomize listen port?".to_string()
549            }),
550        )
551        .default(false);
552
553    if listen_port == interface.listen_port {
554        println!("No change necessary - interface already has this setting.");
555        Ok(None)
556    } else if args.yes || confirmation.interact()? {
557        Ok(Some(listen_port))
558    } else {
559        Ok(None)
560    }
561}
562
563pub fn unspecified_ip_and_auto_detection_flow() -> Result<Option<IpAddr>, Error> {
564    if confirm_unspecified_ip_usage()? {
565        Ok(Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)))
566    } else {
567        ip_auto_detection_flow()
568    }
569}
570
571pub fn ip_auto_detection_flow() -> Result<Option<IpAddr>, Error> {
572    let ip_addr = if confirm_ip_auto_detection()? {
573        innernet_publicip::get_any(Preference::Ipv4)
574    } else {
575        None
576    };
577
578    Ok(ip_addr)
579}
580
581fn confirm_ip_auto_detection() -> Result<bool, Error> {
582    let answer = Confirm::with_theme(&*THEME)
583        .wait_for_newline(true)
584        .with_prompt("Auto-detect external endpoint IP address (via DNS query to 9.9.9.9)?")
585        .interact()?;
586
587    Ok(answer)
588}
589
590fn confirm_unspecified_ip_usage() -> Result<bool, Error> {
591    log::info!(
592        "Note: use unspecified IP address (all zeros) if you do not have a fixed global IP but the \
593         port is forwarded." 
594    );
595    let answer = Confirm::with_theme(&*THEME)
596        .wait_for_newline(true)
597        .with_prompt("Use an unspecified IP address and override just the port?")
598        .interact()?;
599
600    Ok(answer)
601}
602
603pub fn input_external_endpoint(
604    external_ip: Option<IpAddr>,
605    listen_port: u16,
606) -> Result<Endpoint, Error> {
607    let endpoint = input(
608        "External endpoint",
609        match external_ip {
610            Some(ip) => Prefill::Editable(SocketAddr::new(ip, listen_port).to_string()),
611            None => Prefill::None,
612        },
613    )?;
614
615    Ok(endpoint)
616}