use crate::{
interface_config::InterfaceInfo, peer::NewPeerInfo, AddCidrOpts, AddDeleteAssociationOpts,
AddPeerOpts, Association, Cidr, CidrContents, CidrTree, DeleteCidrOpts, EnableDisablePeerOpts,
Endpoint, Error, Hostname, IpNetExt, ListenPortOpts, OverridePeerEndpointOpts, Peer,
PeerContents, RenameCidrOpts, RenamePeerOpts,
};
use anyhow::{anyhow, bail};
use colored::*;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use innernet_publicip::Preference;
use once_cell::sync::Lazy;
use std::{
collections::BTreeMap,
fmt::{Debug, Display},
io,
net::{IpAddr, Ipv4Addr, SocketAddr},
str::FromStr,
};
pub static THEME: Lazy<ColorfulTheme> = Lazy::new(ColorfulTheme::default);
pub fn ensure_interactive(prompt: &str) -> Result<(), io::Error> {
if atty::is(atty::Stream::Stdin) {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::BrokenPipe,
format!("Prompt \"{prompt}\" failed because TTY isn't connected."),
))
}
}
pub fn confirm(prompt: &str) -> Result<bool, dialoguer::Error> {
ensure_interactive(prompt)?;
Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(prompt)
.default(false)
.interact()
}
pub fn select<'a, T: ToString>(
prompt: &str,
items: &'a [T],
) -> Result<(usize, &'a T), dialoguer::Error> {
ensure_interactive(prompt)?;
let choice = Select::with_theme(&*THEME)
.with_prompt(prompt)
.items(items)
.interact()?;
Ok((choice, &items[choice]))
}
pub enum Prefill<T> {
Default(T),
Editable(String),
None,
}
pub fn input<T>(prompt: &str, prefill: Prefill<T>) -> Result<T, dialoguer::Error>
where
T: Clone + FromStr + Display,
T::Err: Display + Debug,
{
ensure_interactive(prompt)?;
let input = Input::with_theme(&*THEME);
match prefill {
Prefill::Default(value) => input.default(value),
Prefill::Editable(value) => input.with_initial_text(value),
_ => input,
}
.with_prompt(prompt)
.interact_text()
}
pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrContents>, Error> {
let parent_cidr = if let Some(parent_name) = &request.parent {
cidrs
.iter()
.find(|cidr| &cidr.name == parent_name)
.ok_or_else(|| anyhow!("No parent CIDR with that name exists."))?
} else {
choose_cidr(cidrs, "Parent CIDR")?
};
let name = if let Some(name) = &request.name {
name.clone()
} else {
input("Name", Prefill::None)?
};
let cidr = if let Some(cidr) = request.cidr {
cidr
} else {
input("CIDR", Prefill::None)?
};
let cidr_request = CidrContents {
name: name.to_string(),
cidr,
parent: Some(parent_cidr.id),
};
Ok(
if request.yes || confirm(&format!("Create CIDR \"{}\"?", cidr_request.name))? {
Some(cidr_request)
} else {
None
},
)
}
pub fn rename_cidr(
cidrs: &[Cidr],
args: &RenameCidrOpts,
) -> Result<Option<(CidrContents, String)>, Error> {
let old_cidr = if let Some(name) = &args.name {
cidrs
.iter()
.find(|c| &c.name == name)
.ok_or_else(|| anyhow!("CIDR '{}' does not exist", name))?
.clone()
} else {
let (cidr_index, _) = select(
"CIDR to rename",
&cidrs.iter().map(|ep| ep.name.clone()).collect::<Vec<_>>(),
)?;
cidrs[cidr_index].clone()
};
let old_name = old_cidr.name.clone();
let new_name = if let Some(name) = &args.new_name {
name.clone()
} else {
input("New Name", Prefill::None)?
};
let mut new_cidr = old_cidr;
new_cidr.contents.name.clone_from(&new_name);
Ok(
if args.yes
|| confirm(&format!(
"Rename CIDR {} to {}?",
old_name.yellow(),
new_name.yellow()
))?
{
Some((new_cidr.contents, old_name))
} else {
None
},
)
}
pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) -> Result<i64, Error> {
let eligible_cidrs: Vec<_> = cidrs
.iter()
.filter(|cidr| {
!peers.iter().any(|peer| peer.contents.cidr_id == cidr.id) &&
!cidrs.iter().any(
|cidr2| matches!(cidr2.contents.parent, Some(parent_id) if parent_id == cidr.id)
)
})
.collect();
let cidr = if let Some(name) = &request.name {
cidrs
.iter()
.find(|cidr| &cidr.name == name)
.ok_or_else(|| anyhow!("CIDR {} doesn't exist or isn't eligible for deletion", name))?
} else {
select("Delete CIDR", &eligible_cidrs)?.1
};
if request.yes || confirm(&format!("Delete CIDR \"{}\"?", cidr.name))? {
Ok(cidr.id)
} else {
Err(anyhow!("Canceled"))
}
}
pub fn choose_cidr<'a>(cidrs: &'a [Cidr], text: &'static str) -> Result<&'a Cidr, Error> {
let eligible_cidrs: Vec<_> = cidrs
.iter()
.filter(|cidr| cidr.name != "innernet-server")
.collect();
Ok(select(text, &eligible_cidrs)?.1)
}
pub fn choose_association<'a>(
associations: &'a [Association],
cidrs: &'a [Cidr],
args: &AddDeleteAssociationOpts,
) -> Result<&'a Association, Error> {
match (&args.cidr1, &args.cidr2) {
(Some(cidr1_name), Some(cidr2_name)) => {
let cidr1 = find_cidr(cidrs, cidr1_name)?;
let cidr2 = find_cidr(cidrs, cidr2_name)?;
associations
.iter()
.find(|association| {
(association.cidr_id_1 == cidr1.id && association.cidr_id_2 == cidr2.id)
|| (association.cidr_id_1 == cidr2.id && association.cidr_id_2 == cidr1.id)
})
.ok_or_else(|| anyhow!("CIDR association does not exist"))
},
_ => {
let names: Vec<_> = associations
.iter()
.map(|association| {
format!(
"{}: {} <=> {}",
association.id,
&cidrs
.iter()
.find(|c| c.id == association.cidr_id_1)
.unwrap()
.name,
&cidrs
.iter()
.find(|c| c.id == association.cidr_id_2)
.unwrap()
.name
)
})
.collect();
let (index, _) = select("Association", &names)?;
Ok(&associations[index])
},
}
}
fn find_cidr<'a>(cidrs: &'a [Cidr], name: &str) -> Result<&'a Cidr, Error> {
cidrs
.iter()
.find(|c| c.name == name)
.ok_or_else(|| anyhow!("can't find cidr '{}'", name))
}
fn find_or_prompt_cidr<'a>(
cidrs: &'a [Cidr],
sub_opt: &Option<String>,
prompt: &'static str,
) -> Result<&'a Cidr, Error> {
if let Some(name) = sub_opt {
find_cidr(cidrs, name)
} else {
choose_cidr(cidrs, prompt)
}
}
pub fn add_association<'a>(
cidrs: &'a [Cidr],
args: &AddDeleteAssociationOpts,
) -> Result<Option<(&'a Cidr, &'a Cidr)>, Error> {
let cidr1 = find_or_prompt_cidr(cidrs, &args.cidr1, "First CIDR")?;
let cidr2 = find_or_prompt_cidr(cidrs, &args.cidr2, "Second CIDR")?;
Ok(
if args.yes
|| confirm(&format!(
"Add association: {} <=> {}?",
cidr1.name.yellow().bold(),
cidr2.name.yellow().bold()
))?
{
Some((cidr1, cidr2))
} else {
None
},
)
}
pub fn print_invitation_info(peer: &Peer, target_file_name: &str) {
println!(
"\nPeer \"{}\" added\n\
Peer invitation file written to {}\n\
Please send it to them securely (eg. via magic-wormhole) \
to bootstrap them onto the network.",
peer.name.bold(),
target_file_name.bold()
);
}
pub fn delete_association<'a>(
associations: &'a [Association],
cidrs: &'a [Cidr],
args: &AddDeleteAssociationOpts,
) -> Result<Option<&'a Association>, Error> {
let association = choose_association(associations, cidrs, args)?;
Ok(
if args.yes || confirm(&format!("Delete association #{}?", association.id))? {
Some(association)
} else {
None
},
)
}
pub fn gather_new_peer_info(
peers: &[Peer],
cidr_tree: &CidrTree,
args: &AddPeerOpts,
) -> Result<Option<(NewPeerInfo, String)>, Error> {
let leaves = cidr_tree.leaves();
let cidr = if let Some(parent_name) = &args.cidr {
leaves
.iter()
.find(|cidr| &cidr.name == parent_name)
.ok_or_else(|| anyhow!("No eligible CIDR with that name exists."))?
} else {
choose_cidr(&leaves[..], "Eligible CIDRs for peer")?
};
let mut available_ip = None;
let candidate_ips = cidr.hosts().filter(|ip| cidr.is_assignable(ip));
for ip in candidate_ips {
if !peers.iter().any(|peer| peer.ip == ip) {
available_ip = Some(ip);
break;
}
}
let available_ip = available_ip.expect("No IPs in this CIDR are avavilable");
let ip = if let Some(ip) = args.ip {
ip
} else if args.auto_ip {
available_ip
} else {
input("IP", Prefill::Default(available_ip))?
};
let name = if let Some(name) = &args.name {
name.clone()
} else {
input("Name", Prefill::None)?
};
let is_admin = if let Some(is_admin) = args.admin {
is_admin
} else {
confirm(&format!("Make {name} an admin?"))?
};
let invite_expires = if let Some(invite_expires) = &args.invite_expires {
invite_expires.clone()
} else {
input(
"Invite expires after",
Prefill::Default("14d".parse().map_err(|s: &str| anyhow!(s))?),
)?
};
let invite_save_path = if let Some(location) = &args.save_config {
location.clone()
} else {
input(
"Save peer invitation file to",
Prefill::Default(format!("{name}.toml")),
)?
};
if args.yes || confirm(&format!("Create peer {}?", name.yellow()))? {
let input = NewPeerInfo {
name,
ip,
cidr_id: cidr.id,
is_admin,
invite_expires,
};
Ok(Some((input, invite_save_path)))
} else {
Ok(None)
}
}
pub fn rename_peer(
peers: &[Peer],
args: &RenamePeerOpts,
) -> Result<Option<(PeerContents, Hostname)>, Error> {
let eligible_peers = peers
.iter()
.filter(|p| &*p.name != "innernet-server")
.collect::<Vec<_>>();
let old_peer = if let Some(name) = &args.name {
eligible_peers
.into_iter()
.find(|p| &p.name == name)
.ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
.clone()
} else {
let (peer_index, _) = select(
"Peer to rename",
&eligible_peers
.iter()
.map(|ep| ep.name.clone())
.collect::<Vec<_>>(),
)?;
eligible_peers[peer_index].clone()
};
let old_name = old_peer.name.clone();
let new_name = if let Some(name) = &args.new_name {
name.clone()
} else {
input("New Name", Prefill::None)?
};
let mut new_peer = old_peer;
new_peer.contents.name = new_name.clone();
Ok(
if args.yes
|| confirm(&format!(
"Rename peer {} to {}?",
old_name.yellow(),
new_name.yellow()
))?
{
Some((new_peer.contents, old_name))
} else {
None
},
)
}
pub fn enable_or_disable_peer(
peers: &[Peer],
args: &EnableDisablePeerOpts,
enable: bool,
) -> Result<Option<Peer>, Error> {
let enabled_peers: Vec<_> = peers
.iter()
.filter(|peer| enable && peer.is_disabled || !enable && !peer.is_disabled)
.collect();
let peer = if let Some(name) = &args.name {
enabled_peers
.into_iter()
.find(|p| &p.name == name)
.ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
} else {
let peer_selection: Vec<_> = enabled_peers
.iter()
.map(|peer| format!("{} ({})", &peer.name, &peer.ip))
.collect();
let (index, _) = select(
&format!("Peer to {}able", if enable { "en" } else { "dis" }),
&peer_selection,
)?;
enabled_peers[index]
};
Ok(
if args.yes
|| confirm(&format!(
"{}able peer {}?",
if enable { "En" } else { "Dis" },
peer.name.yellow()
))?
{
Some(peer.clone())
} else {
None
},
)
}
pub fn set_listen_port(
interface: &InterfaceInfo,
args: ListenPortOpts,
) -> Result<Option<Option<u16>>, Error> {
let listen_port = if let Some(listen_port) = args.listen_port {
Some(listen_port)
} else if !args.unset {
Some(input(
"Listen port",
Prefill::Default(interface.listen_port.unwrap_or(51820)),
)?)
} else {
None
};
let confirmation = Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt(
&(if let Some(port) = &listen_port {
format!("Set listen port to {port}?")
} else {
"Unset and randomize listen port?".to_string()
}),
)
.default(false);
if listen_port == interface.listen_port {
println!("No change necessary - interface already has this setting.");
Ok(None)
} else if args.yes || confirmation.interact()? {
Ok(Some(listen_port))
} else {
Ok(None)
}
}
pub fn unspecified_ip_and_auto_detection_flow() -> Result<Option<IpAddr>, Error> {
if confirm_unspecified_ip_usage()? {
Ok(Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)))
} else {
ip_auto_detection_flow()
}
}
pub fn ip_auto_detection_flow() -> Result<Option<IpAddr>, Error> {
let ip_addr = if confirm_ip_auto_detection()? {
innernet_publicip::get_any(Preference::Ipv4)
} else {
None
};
Ok(ip_addr)
}
fn confirm_ip_auto_detection() -> Result<bool, Error> {
let answer = Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt("Auto-detect external endpoint IP address (via DNS query to 9.9.9.9)?")
.interact()?;
Ok(answer)
}
fn confirm_unspecified_ip_usage() -> Result<bool, Error> {
log::info!(
"Note: use unspecified IP address (all zeros) if you do not have a fixed global IP but the \
port is forwarded."
);
let answer = Confirm::with_theme(&*THEME)
.wait_for_newline(true)
.with_prompt("Use an unspecified IP address and override just the port?")
.interact()?;
Ok(answer)
}
pub fn input_external_endpoint(
external_ip: Option<IpAddr>,
listen_port: u16,
) -> Result<Endpoint, Error> {
let endpoint = input(
"External endpoint",
match external_ip {
Some(ip) => Prefill::Editable(SocketAddr::new(ip, listen_port).to_string()),
None => Prefill::None,
},
)?;
Ok(endpoint)
}
pub fn override_peer_endpoint_prompt(
peers: &[Peer],
peer_endpoint_overrides: &BTreeMap<IpAddr, Endpoint>,
args: &OverridePeerEndpointOpts,
) -> Result<Option<(Peer, Option<Endpoint>)>, Error> {
let eligible_peers = peers
.iter()
.filter(|p| &*p.name != "innernet-server")
.filter(|p| !args.unset || peer_endpoint_overrides.contains_key(&p.ip))
.collect::<Vec<_>>();
let peer = if let Some(name) = &args.name {
let Some(peer) = eligible_peers.into_iter().find(|p| &p.name == name) else {
return if args.unset && peers.iter().find(|p| &p.name == name).is_some() {
log::info!("Peer '{name}' does not have an override set");
Ok(None)
} else {
Err(anyhow!("Peer '{name}' does not exist"))
};
};
peer.clone()
} else {
let message = if args.unset {
if eligible_peers.is_empty() {
bail!("No peers have an override endpoint set");
}
"Peer endpoint override to unset"
} else {
"Peer endpoint to override"
};
let (peer_index, _) = select(
message,
&eligible_peers
.iter()
.map(|ep| ep.name.clone())
.collect::<Vec<_>>(),
)?;
eligible_peers[peer_index].clone()
};
let endpoint: Option<Endpoint> = if args.unset {
None
} else if let Some(endpoint) = &args.endpoint {
Some(endpoint.clone())
} else {
Some(input("Endpoint", Prefill::None)?)
};
let confirm_msg = if let Some(endpoint) = &endpoint {
&format!(
"Override endpoint for peer {} ({}) to {}?",
peer.name.yellow(),
peer.ip,
endpoint,
)
} else {
&format!(
"Unset endpoint override for peer {} ({})?",
peer.name.yellow(),
peer.ip,
)
};
Ok(if args.yes || confirm(confirm_msg)? {
Some((peer, endpoint))
} else {
None
})
}