use std::{
io::{self, Write},
net::{IpAddr, Ipv6Addr, SocketAddr, ToSocketAddrs},
sync::LazyLock,
time::Duration,
};
use clap::{Parser, ValueEnum};
use indoc::indoc;
use regex::{self, Regex};
use rustyline_async::{Readline, ReadlineEvent};
use tokio::{net::TcpListener, signal};
use crate::{
protocol::{
self, AuthVersion, Command, Connection, ExitBreak, OutputType, SecureMsg, TerminateCmd,
XWingKeypair,
},
sigint, util,
};
#[derive(Clone, Debug)]
enum BinaryLoc {
SystemPath,
FetchHosted,
Provided(String),
}
impl BinaryLoc {
fn parse_arg(value: &str) -> Result<BinaryLoc, String> {
match value {
"@PATH" => Ok(BinaryLoc::SystemPath),
"@FETCH" => Ok(BinaryLoc::FetchHosted),
path => match path.chars().next() {
None => Err("Path may not be empty")?,
Some('.' | '/') => Ok(BinaryLoc::Provided(path.to_owned())),
_ => Err("Path must start with '.' or '/'")?,
},
}
}
fn format_arg(&self) -> String {
match self {
BinaryLoc::SystemPath => String::from("'@PATH'"),
BinaryLoc::FetchHosted => String::from("'@FETCH'"),
BinaryLoc::Provided(path) => util::shell_escape(path),
}
}
}
#[derive(Parser, Debug)]
pub struct Cli {
#[arg(long, value_enum)]
auth: Option<AuthVersion>,
#[arg(required = true, value_name = "HOST:PORT")]
callback: String,
#[arg(verbatim_doc_comment)]
#[doc = "Binary path to use in breakpoint command line.
This only changes what the suggested invocation will be when printing \
the breakmancer command to use on the breakpoint side; you can always \
adjust it manually as needed.
- Use the special string `@PATH` to expect a binary called `breakmancer` \
on the system path.
- Use the special string `@FETCH` to create a command line that will \
fetch the binary on demand from a server.
- Use any path starting with `/` or `.` to specify a path to a binary on \
the breakpoint side, e.g. `/opt/bin/breakmancer` or `./bin/breakmancer`.
If not specified, you will be asked interactively which one you prefer."]
#[arg(long = "bin")]
binary: Option<String>,
#[arg(long)]
local_port: Option<u16>,
}
struct Params {
pub callback: String,
pub binary: BinaryLoc,
pub listen: SocketAddr,
}
const UNSPECIFIED_IP: IpAddr = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0));
fn protocol_wizard() -> Result<AuthVersion, String> {
println!(
"Will you be able to view stdout from your process as it runs? \
This is required for finding the verification string during \
connection setup.\n"
);
print!("Answer [y/n]: ");
io::stdout().flush().unwrap();
let mut answer = String::new();
io::stdin().read_line(&mut answer).unwrap();
println!();
println!("============================================================");
println!();
match answer.trim().to_lowercase().as_str() {
"y" | "yes" => Ok(AuthVersion::DualPub),
"n" | "no" => Err("This program currently cannot meet your needs. \
If after reviewing your answers you find they are unchanged, \
consider submitting a bug report describing your use-case \
and its constraints."
.to_string()),
_ => Err("Unrecognized answer".to_string()),
}
}
fn binary_source_wizard() -> Result<BinaryLoc, String> {
println!("How will you be running breakmancer on the breakpoint side?");
println!();
println!("1) It's already installed globally or otherwise is on the PATH, i.e. `breakmancer`");
println!("2) I'd like it to be downloaded temporarily from the maintainer's server");
println!(
"3) I've installed the binary in some other location (give me a \
command line and I'll adjust it myself)"
);
println!();
print!("Choice [1/2/3]: ");
io::stdout().flush().unwrap();
let mut answer = String::new();
io::stdin().read_line(&mut answer).unwrap();
println!();
#[allow(clippy::items_after_statements)]
fn ask_for_path() -> Result<String, String> {
println!("Please enter the path on the breakpoint side:\n");
let mut answer = String::new();
io::stdin().read_line(&mut answer).unwrap();
println!();
let answer = answer.trim_end_matches(['\r', '\n']).to_string();
if answer.is_empty() {
Err("Provided path was empty".to_string())
} else if answer.starts_with(['/', '.']) {
Ok(answer)
} else {
Ok(format!("./{answer}"))
}
}
let answer = match answer.trim().to_lowercase().as_str() {
"1" => Ok(BinaryLoc::SystemPath),
"2" => Ok(BinaryLoc::FetchHosted),
"3" => Ok(BinaryLoc::Provided(ask_for_path()?)),
_ => Err("Unrecognized answer".to_string()),
};
println!("============================================================");
println!();
answer
}
fn parse_args(args: &Cli) -> Result<Params, String> {
let mut tips = vec![];
let (_, callback_port) = util::parse_address(&args.callback)
.map_err(|err| format!("Invalid callback address: {err}"))?;
match &args.callback.to_socket_addrs() {
Ok(addrs) => {
if addrs.len() == 0 {
eprintln!("[WARNING] Callback host did not resolve to any IP addresses.");
}
}
Err(err) => eprintln!("[WARNING] Could not resolve callback host to an IP address: {err}"),
}
let listen_port = args.local_port.unwrap_or(callback_port);
let binary_maybe = args
.binary
.as_ref()
.map(|choice| BinaryLoc::parse_arg(choice).map_err(|err| format!("--bin: {err}")))
.transpose()?;
if args.auth.is_none() {
let chosen = protocol_wizard()?;
let arg_val = chosen
.to_possible_value()
.expect("None of the selectable AuthVersion variants should be skipped.");
tips.push(format!("--auth={}", arg_val.get_name()));
}
let binary = if let Some(choice) = binary_maybe {
choice
} else {
let chosen = binary_source_wizard()?;
tips.push(format!("--bin={}", chosen.format_arg()));
chosen
};
if !tips.is_empty() {
println!(
"Tip: The following command line options contain your selections, \
and will allow you to skip those questions in the future:\n\n {}",
tips.join(" "),
);
println!();
println!("============================================================");
println!();
}
Ok(Params {
callback: args.callback.to_string(),
binary,
listen: SocketAddr::new(UNSPECIFIED_IP, listen_port),
})
}
fn print_with_trailing_newline(data: &[u8]) {
print!("{}", String::from_utf8_lossy(data));
if !data.ends_with("\n".as_bytes()) {
println!();
}
}
const CMD_EXIT: &str = "exit";
static CMD_EXIT_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!("(?i)\\s*{CMD_EXIT}\\s*")).unwrap());
enum ReplInput {
Command(String),
Interrupted,
}
async fn ask_user_next_command(
cmd_history: &mut Vec<String>,
sigint_manager: &mut sigint::Manager,
) -> Result<ReplInput, String> {
let (mut readline, rl_writer) = Readline::new(">> ".to_string())
.map_err(|err| format!("Could not set up input prompt: {err}"))?;
for cmd in &mut *cmd_history {
readline.add_history_entry((*cmd).to_string());
}
let suppress_sigint = sigint_manager.suppress();
let readline_result = readline.readline().await;
suppress_sigint.release();
readline.flush().unwrap();
drop(rl_writer);
match readline_result {
Ok(ReadlineEvent::Line(command)) => Ok(ReplInput::Command(command)),
Ok(ReadlineEvent::Eof | ReadlineEvent::Interrupted) => Ok(ReplInput::Interrupted),
Err(err) => Err(format!("Couldn't read input: {err}")),
}
}
async fn hangup(conn: Connection) -> Result<(), String> {
conn.send(SecureMsg::ExitBreak(ExitBreak {}))
.await
.map_err(|err| format!("Could not tell breakpoint to exit: {err}"))?;
Ok(())
}
enum AfterSession {
Iterate,
Exit,
}
async fn run_session(
mut conn: Connection,
cmd_history: &mut Vec<String>,
sigint_manager: &mut sigint::Manager,
) -> Result<AfterSession, String> {
println!("Session ready. You can enter single-line commands. Use `{CMD_EXIT}` to exit.");
let mut cmd_seq = 0;
loop {
let command: Option<String> =
match ask_user_next_command(cmd_history, sigint_manager).await? {
ReplInput::Command(command) => {
if command.is_empty() {
continue; }
if CMD_EXIT_PATTERN.is_match(&command) {
None
} else {
Some(command)
}
}
ReplInput::Interrupted => None,
};
let Some(command) = command else {
match ask_user_exit_choice().await {
ExitChoice::Resume => {
continue;
}
ExitChoice::Iterate => {
hangup(conn).await?;
return Ok(AfterSession::Iterate);
}
ExitChoice::Exit => {
hangup(conn).await?;
return Ok(AfterSession::Exit);
}
}
};
if !command.starts_with(' ') && Some(&command) != cmd_history.last() {
cmd_history.push(command.clone());
}
conn.send(SecureMsg::Command(Command {
command: command.clone(),
seq: cmd_seq,
}))
.await
.map_err(|err| format!("Could not send command to breakpoint: {err}"))?;
let mut killing = false;
loop {
tokio::select! {
biased;
_ = signal::ctrl_c() => {
if !killing {
println!(); println!("Attempting to terminate command...");
conn.send(
SecureMsg::TerminateCmd(TerminateCmd { seq: cmd_seq })
).await.map_err(|err| format!("Could not send termination command to breakpoint: {err}"))?;
killing = true;
}
},
msg = conn.recv() => match msg.ok_or("Error reading from breakpoint.")? {
SecureMsg::OutputLine(output) => {
if output.seq != cmd_seq {
return Err(format!(
"Received output for command #{} instead of #{}",
output.seq, cmd_seq
));
}
let log_tag = match output.gender {
OutputType::Stdout => "out",
OutputType::Stderr => "err",
};
print!("[{log_tag}] ");
print_with_trailing_newline(&output.bytes);
}
SecureMsg::Finished(outcome) => {
if outcome.seq != cmd_seq {
return Err(format!(
"Received completion notification for command #{} instead of #{}",
outcome.seq, cmd_seq
));
}
match outcome.exit_code {
Some(status) => println!("[exit: {status}]"),
None => println!("[exit: terminated by signal]"),
}
break;
}
wrong_variant => Err(format!(
"Received unexpected message type: {}",
Into::<&str>::into(wrong_variant)
))?,
},
};
}
cmd_seq += 1;
}
}
enum ExitChoice {
Resume,
Iterate,
Exit,
}
async fn ask_user_exit_choice() -> ExitChoice {
println!(indoc! {
"You've left the command shell. Do you want to resume, or exit?
[1] Resume: Go back to issuing commands to the breakpoint.
[2] Iterate: Exit from breakpoint on remote end, running script normally, but leave controller running with same keys.
[3] Exit: Exit from breakpoint and controller. For additional debugging you'll need to provision the breakpoint with a new key and secret."
});
println!();
let (mut readline, mut rl_writer) = Readline::new("Choice [1/2/3]: ".to_string()).unwrap();
let choice = loop {
match readline.readline().await {
Ok(ReadlineEvent::Line(choice)) => match choice.as_str() {
"1" => break ExitChoice::Resume,
"2" => break ExitChoice::Iterate,
"3" => break ExitChoice::Exit,
_ => (),
},
Ok(ReadlineEvent::Eof) => (),
Ok(ReadlineEvent::Interrupted) => break ExitChoice::Exit,
Err(err) => {
rl_writer
.write_all(format!("Failed to read input, exiting: {err}\n").as_bytes())
.unwrap();
break ExitChoice::Exit;
}
}
};
readline.flush().unwrap(); choice
}
fn print_connection_instructions(controller_keypair: &XWingKeypair, params: &Params) {
let controller_digest = protocol::controller_digest(&controller_keypair.public_key);
let binary_base = match ¶ms.binary {
BinaryLoc::SystemPath => String::from("breakmancer"),
BinaryLoc::FetchHosted => String::from(
"curl -sS https://codeberg.org/timmc/breakmancer/raw/branch/master/fetch-and-run.sh \
\\\n | bash -s --",
),
BinaryLoc::Provided(path) => util::shell_escape(path),
};
println!(
indoc! {r#"Run the following command from the environment you want to inspect:
{} break --auth dual-pub -c {} -i {}
You can add --which="some location" to distinguish between breakpoints.
(Now listening on {} for callback...)"#},
binary_base, ¶ms.callback, &controller_digest, ¶ms.listen,
);
}
pub async fn run(args: &Cli) -> Result<(), String> {
let params = parse_args(args)?;
let (controller_keypair, debug_breakpoint_digest) = match crate::debug_utils::get_debug_keys() {
Some(keys) => (
keys.controller_keypair,
Some(protocol::breakpoint_digest(
&keys.breakpoint_keypair.public_key,
)),
),
None => (XWingKeypair::new(), None),
};
let mut sigint_manager = sigint::Manager::setup();
let server_socket = TcpListener::bind(params.listen)
.await
.map_err(|err| format!("Could not listen on {}: {}", ¶ms.listen, err))?;
print_connection_instructions(&controller_keypair, ¶ms);
let mut command_history = Vec::<String>::new();
loop {
let backoff = Duration::from_secs(10);
let conn_partial = match protocol::controller_open_connection(
controller_keypair.clone(),
&server_socket,
)
.await
{
Ok(conn) => conn,
Err(err) => {
println!(
"Failed to start controller connection; waiting {backoff:?} and trying again: {err}"
);
tokio::time::sleep(backoff).await;
continue;
}
};
let expected_breakpoint_digest = match debug_breakpoint_digest.as_ref() {
None => {
print!("Enter the verification string the breakpoint has printed to stdout: ");
io::stdout().flush().unwrap();
let mut expected_breakpoint_digest = String::new();
io::stdin()
.read_line(&mut expected_breakpoint_digest)
.map_err(|err| format!("Error while reading input: {err}"))?;
expected_breakpoint_digest
}
Some(debug_value) => {
println!("[WARNING] Using debug breakpoint digest.");
debug_value.clone()
}
};
let (conn, breakpoint_intro) = match conn_partial
.verify_breakpoint_and_complete_setup(&expected_breakpoint_digest)
.await
{
Ok(conn_and_info) => conn_and_info,
Err(err) => {
println!("Unable to complete connection: {err}");
continue;
}
};
if let Some(which) = breakpoint_intro.which {
println!("Breakpoint: {which}");
}
match run_session(conn, &mut command_history, &mut sigint_manager).await {
Ok(AfterSession::Iterate) => {
println!("Will wait for new connection...");
}
Ok(AfterSession::Exit) => {
return Ok(());
}
Err(err) => {
println!("Encountered error, restarting connection: {err}");
}
}
}
}