This PR adds support for configuring the client cert and private key for establishing a mutual TLS connection to a server.

Example:
```
GET https://localhost:8443/
[Options]
cacert: out/HurlCA.crt
cert: out/client.crt
key: out/client.key

HTTP 200
```

outputs:
```
Request can be run with the following curl command:
* curl 'https://localhost:8443/' --cacert out/HurlCA.crt --cert out/client.crt --key out/client.key
```
This commit is contained in:
Tim Bart 2022-11-28 10:43:43 -08:00
parent e2fd4405c3
commit 04ebc958c6
No known key found for this signature in database
10 changed files with 234 additions and 0 deletions

View File

@ -34,6 +34,8 @@ use crate::runner::Value;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliOptions { pub struct CliOptions {
pub cacert_file: Option<String>, pub cacert_file: Option<String>,
pub client_cert_file: Option<String>,
pub client_key_file: Option<String>,
pub color: bool, pub color: bool,
pub compressed: bool, pub compressed: bool,
pub connect_timeout: Duration, pub connect_timeout: Duration,
@ -105,6 +107,21 @@ pub fn app(version: &str) -> Command {
.help("CA certificate to verify peer against (PEM format)") .help("CA certificate to verify peer against (PEM format)")
.num_args(1) .num_args(1)
) )
.arg(
clap::Arg::new("client_cert_file")
.long("cert")
.value_name("FILE")
.help("Client certificate file and password")
.num_args(1)
.short('E')
)
.arg(
clap::Arg::new("client_key_file")
.long("key")
.value_name("FILE")
.help("Private key file name")
.num_args(1)
)
.arg( .arg(
clap::Arg::new("color") clap::Arg::new("color")
.long("color") .long("color")
@ -378,6 +395,28 @@ pub fn parse_options(matches: &ArgMatches) -> Result<CliOptions, CliError> {
} }
} }
}; };
let client_cert_file = match get::<String>(matches, "client_cert_file") {
None => None,
Some(filename) => {
if !Path::new(&filename).is_file() {
let message = format!("File {} does not exist", filename);
return Err(CliError { message });
} else {
Some(filename)
}
}
};
let client_key_file = match get::<String>(matches, "client_key_file") {
None => None,
Some(filename) => {
if !Path::new(&filename).is_file() {
let message = format!("File {} does not exist", filename);
return Err(CliError { message });
} else {
Some(filename)
}
}
};
let color = output_color(matches); let color = output_color(matches);
let compressed = has_flag(matches, "compressed"); let compressed = has_flag(matches, "compressed");
let connect_timeout = get::<u64>(matches, "connect_timeout").unwrap(); let connect_timeout = get::<u64>(matches, "connect_timeout").unwrap();
@ -450,6 +489,8 @@ pub fn parse_options(matches: &ArgMatches) -> Result<CliOptions, CliError> {
Ok(CliOptions { Ok(CliOptions {
cacert_file, cacert_file,
client_cert_file,
client_key_file,
color, color,
compressed, compressed,
connect_timeout, connect_timeout,

View File

@ -144,6 +144,16 @@ impl Client {
self.handle.ssl_cert_type("PEM").unwrap(); self.handle.ssl_cert_type("PEM").unwrap();
} }
if let Some(client_cert_file) = options.client_cert_file.clone() {
self.handle.ssl_cert(client_cert_file).unwrap();
self.handle.ssl_cert_type("PEM").unwrap();
}
if let Some(client_key_file) = options.client_key_file.clone() {
self.handle.ssl_key(client_key_file).unwrap();
self.handle.ssl_cert_type("PEM").unwrap();
}
if let Some(proxy) = options.proxy.clone() { if let Some(proxy) = options.proxy.clone() {
self.handle.proxy(proxy.as_str()).unwrap(); self.handle.proxy(proxy.as_str()).unwrap();
} }

View File

@ -20,6 +20,8 @@ use std::time::Duration;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClientOptions { pub struct ClientOptions {
pub cacert_file: Option<String>, pub cacert_file: Option<String>,
pub client_cert_file: Option<String>,
pub client_key_file: Option<String>,
pub follow_location: bool, pub follow_location: bool,
pub max_redirect: Option<usize>, pub max_redirect: Option<usize>,
pub cookie_input_file: Option<String>, pub cookie_input_file: Option<String>,
@ -45,6 +47,8 @@ impl Default for ClientOptions {
fn default() -> Self { fn default() -> Self {
ClientOptions { ClientOptions {
cacert_file: None, cacert_file: None,
client_cert_file: None,
client_key_file: None,
follow_location: false, follow_location: false,
max_redirect: Some(50), max_redirect: Some(50),
cookie_input_file: None, cookie_input_file: None,
@ -71,6 +75,16 @@ impl ClientOptions {
arguments.push(cacert_file.clone()); arguments.push(cacert_file.clone());
} }
if let Some(ref client_cert_file) = self.client_cert_file {
arguments.push("--cert".to_string());
arguments.push(client_cert_file.clone());
}
if let Some(ref client_key_file) = self.client_key_file {
arguments.push("--key".to_string());
arguments.push(client_key_file.clone());
}
if self.compressed { if self.compressed {
arguments.push("--compressed".to_string()); arguments.push("--compressed".to_string());
} }
@ -130,6 +144,8 @@ mod tests {
assert_eq!( assert_eq!(
ClientOptions { ClientOptions {
cacert_file: None, cacert_file: None,
client_cert_file: None,
client_key_file: None,
follow_location: true, follow_location: true,
max_redirect: Some(10), max_redirect: Some(10),
cookie_input_file: Some("cookie_file".to_string()), cookie_input_file: Some("cookie_file".to_string()),

View File

@ -108,6 +108,7 @@ fn execute(
logger.debug(format!(" fail fast: {}", cli_options.fail_fast).as_str()); logger.debug(format!(" fail fast: {}", cli_options.fail_fast).as_str());
logger.debug(format!(" follow redirect: {}", cli_options.follow_location).as_str()); logger.debug(format!(" follow redirect: {}", cli_options.follow_location).as_str());
logger.debug(format!(" insecure: {}", cli_options.insecure).as_str()); logger.debug(format!(" insecure: {}", cli_options.insecure).as_str());
if let Some(n) = cli_options.max_redirect { if let Some(n) = cli_options.max_redirect {
logger.debug(format!(" max redirect: {}", n).as_str()); logger.debug(format!(" max redirect: {}", n).as_str());
} }

View File

@ -217,6 +217,8 @@ impl From<&RunnerOptions> for ClientOptions {
fn from(runner_options: &RunnerOptions) -> Self { fn from(runner_options: &RunnerOptions) -> Self {
ClientOptions { ClientOptions {
cacert_file: runner_options.cacert_file.clone(), cacert_file: runner_options.cacert_file.clone(),
client_cert_file: runner_options.client_cert_file.clone(),
client_key_file: runner_options.client_key_file.clone(),
follow_location: runner_options.follow_location, follow_location: runner_options.follow_location,
max_redirect: runner_options.max_redirect, max_redirect: runner_options.max_redirect,
cookie_input_file: runner_options.cookie_input_file.clone(), cookie_input_file: runner_options.cookie_input_file.clone(),
@ -300,6 +302,14 @@ pub fn get_entry_options(
runner_options.cacert_file = Some(option.filename.value.clone()); runner_options.cacert_file = Some(option.filename.value.clone());
logger.debug(format!("cacert: {}", option.filename.value).as_str()); logger.debug(format!("cacert: {}", option.filename.value).as_str());
} }
EntryOption::ClientCert(option) => {
runner_options.client_cert_file = Some(option.filename.value.clone());
logger.debug(format!("cert: {}", option.filename.value).as_str());
}
EntryOption::ClientKey(option) => {
runner_options.client_key_file = Some(option.filename.value.clone());
logger.debug(format!("key: {}", option.filename.value).as_str());
}
EntryOption::Compressed(option) => { EntryOption::Compressed(option) => {
runner_options.compressed = option.value; runner_options.compressed = option.value;
logger.debug(format!("compressed: {}", option.value).as_str()); logger.debug(format!("compressed: {}", option.value).as_str());

View File

@ -26,6 +26,8 @@ use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunnerOptions { pub struct RunnerOptions {
pub cacert_file: Option<String>, pub cacert_file: Option<String>,
pub client_cert_file: Option<String>,
pub client_key_file: Option<String>,
pub compressed: bool, pub compressed: bool,
pub connect_timeout: Duration, pub connect_timeout: Duration,
pub context_dir: ContextDir, pub context_dir: ContextDir,
@ -54,6 +56,8 @@ impl Default for RunnerOptions {
fn default() -> Self { fn default() -> Self {
RunnerOptions { RunnerOptions {
cacert_file: None, cacert_file: None,
client_cert_file: None,
client_key_file: None,
compressed: false, compressed: false,
connect_timeout: Duration::from_secs(300), connect_timeout: Duration::from_secs(300),
context_dir: Default::default(), context_dir: Default::default(),
@ -83,6 +87,8 @@ impl Default for RunnerOptions {
impl RunnerOptions { impl RunnerOptions {
pub fn from(filename: &str, current_dir: &Path, cli_options: &CliOptions) -> Self { pub fn from(filename: &str, current_dir: &Path, cli_options: &CliOptions) -> Self {
let cacert_file = cli_options.cacert_file.clone(); let cacert_file = cli_options.cacert_file.clone();
let client_cert_file = cli_options.client_cert_file.clone();
let client_key_file = cli_options.client_key_file.clone();
let follow_location = cli_options.follow_location; let follow_location = cli_options.follow_location;
let verbosity = match (cli_options.verbose, cli_options.very_verbose) { let verbosity = match (cli_options.verbose, cli_options.very_verbose) {
(true, true) => Some(Verbosity::VeryVerbose), (true, true) => Some(Verbosity::VeryVerbose),
@ -130,6 +136,8 @@ impl RunnerOptions {
let very_verbose = cli_options.very_verbose; let very_verbose = cli_options.very_verbose;
RunnerOptions { RunnerOptions {
cacert_file, cacert_file,
client_cert_file,
client_key_file,
compressed, compressed,
connect_timeout, connect_timeout,
context_dir, context_dir,

View File

@ -694,6 +694,8 @@ pub struct Variable {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum EntryOption { pub enum EntryOption {
CaCertificate(CaCertificateOption), CaCertificate(CaCertificateOption),
ClientCert(ClientCertOption),
ClientKey(ClientKeyOption),
Compressed(CompressedOption), Compressed(CompressedOption),
Insecure(InsecureOption), Insecure(InsecureOption),
FollowLocation(FollowLocationOption), FollowLocation(FollowLocationOption),
@ -736,6 +738,26 @@ pub struct CaCertificateOption {
pub line_terminator0: LineTerminator, pub line_terminator0: LineTerminator,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientCertOption {
pub line_terminators: Vec<LineTerminator>,
pub space0: Whitespace,
pub space1: Whitespace,
pub space2: Whitespace,
pub filename: Filename,
pub line_terminator0: LineTerminator,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientKeyOption {
pub line_terminators: Vec<LineTerminator>,
pub space0: Whitespace,
pub space1: Whitespace,
pub space2: Whitespace,
pub filename: Filename,
pub line_terminator0: LineTerminator,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RetryOption { pub struct RetryOption {
pub line_terminators: Vec<LineTerminator>, pub line_terminators: Vec<LineTerminator>,

View File

@ -234,6 +234,8 @@ impl Htmlable for EntryOption {
fn to_html(&self) -> String { fn to_html(&self) -> String {
match self { match self {
EntryOption::CaCertificate(option) => option.to_html(), EntryOption::CaCertificate(option) => option.to_html(),
EntryOption::ClientCert(option) => option.to_html(),
EntryOption::ClientKey(option) => option.to_html(),
EntryOption::Compressed(option) => option.to_html(), EntryOption::Compressed(option) => option.to_html(),
EntryOption::Insecure(option) => option.to_html(), EntryOption::Insecure(option) => option.to_html(),
EntryOption::FollowLocation(option) => option.to_html(), EntryOption::FollowLocation(option) => option.to_html(),
@ -299,6 +301,40 @@ impl Htmlable for CaCertificateOption {
} }
} }
impl Htmlable for ClientCertOption {
fn to_html(&self) -> String {
let mut buffer = String::from("");
add_line_terminators(&mut buffer, self.line_terminators.clone());
buffer.push_str("<span class=\"line\">");
buffer.push_str(self.space0.to_html().as_str());
buffer.push_str("<span class=\"string\">cert</span>");
buffer.push_str(self.space1.to_html().as_str());
buffer.push_str("<span>:</span>");
buffer.push_str(self.space2.to_html().as_str());
buffer.push_str(self.filename.to_html().as_str());
buffer.push_str("</span>");
buffer.push_str(self.line_terminator0.to_html().as_str());
buffer
}
}
impl Htmlable for ClientKeyOption {
fn to_html(&self) -> String {
let mut buffer = String::from("");
add_line_terminators(&mut buffer, self.line_terminators.clone());
buffer.push_str("<span class=\"line\">");
buffer.push_str(self.space0.to_html().as_str());
buffer.push_str("<span class=\"string\">key</span>");
buffer.push_str(self.space1.to_html().as_str());
buffer.push_str("<span>:</span>");
buffer.push_str(self.space2.to_html().as_str());
buffer.push_str(self.filename.to_html().as_str());
buffer.push_str("</span>");
buffer.push_str(self.line_terminator0.to_html().as_str());
buffer
}
}
impl Htmlable for FollowLocationOption { impl Htmlable for FollowLocationOption {
fn to_html(&self) -> String { fn to_html(&self) -> String {
let mut buffer = String::from(""); let mut buffer = String::from("");

View File

@ -349,6 +349,8 @@ fn option(reader: &mut Reader) -> ParseResult<'static, EntryOption> {
choice( choice(
&[ &[
option_cacert, option_cacert,
option_cert,
option_key,
option_compressed, option_compressed,
option_insecure, option_insecure,
option_follow_location, option_follow_location,
@ -386,6 +388,50 @@ fn option_cacert(reader: &mut Reader) -> ParseResult<'static, EntryOption> {
Ok(EntryOption::CaCertificate(option)) Ok(EntryOption::CaCertificate(option))
} }
fn option_cert(reader: &mut Reader) -> ParseResult<'static, EntryOption> {
let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?;
try_literal("cert", reader)?;
let space1 = zero_or_more_spaces(reader)?;
try_literal(":", reader)?;
let space2 = zero_or_more_spaces(reader)?;
let f = filename::parse(reader)?;
let line_terminator0 = line_terminator(reader)?;
let option = ClientCertOption {
line_terminators,
space0,
space1,
space2,
filename: f,
line_terminator0,
};
Ok(EntryOption::ClientCert(option))
}
fn option_key(reader: &mut Reader) -> ParseResult<'static, EntryOption> {
let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?;
try_literal("key", reader)?;
let space1 = zero_or_more_spaces(reader)?;
try_literal(":", reader)?;
let space2 = zero_or_more_spaces(reader)?;
let f = filename::parse(reader)?;
let line_terminator0 = line_terminator(reader)?;
let option = ClientKeyOption {
line_terminators,
space0,
space1,
space2,
filename: f,
line_terminator0,
};
Ok(EntryOption::ClientKey(option))
}
fn option_compressed(reader: &mut Reader) -> ParseResult<'static, EntryOption> { fn option_compressed(reader: &mut Reader) -> ParseResult<'static, EntryOption> {
let line_terminators = optional_line_terminators(reader)?; let line_terminators = optional_line_terminators(reader)?;
let space0 = zero_or_more_spaces(reader)?; let space0 = zero_or_more_spaces(reader)?;

View File

@ -842,6 +842,8 @@ impl Tokenizable for EntryOption {
fn tokenize(&self) -> Vec<Token> { fn tokenize(&self) -> Vec<Token> {
match self { match self {
EntryOption::CaCertificate(option) => option.tokenize(), EntryOption::CaCertificate(option) => option.tokenize(),
EntryOption::ClientCert(option) => option.tokenize(),
EntryOption::ClientKey(option) => option.tokenize(),
EntryOption::Compressed(option) => option.tokenize(), EntryOption::Compressed(option) => option.tokenize(),
EntryOption::Insecure(option) => option.tokenize(), EntryOption::Insecure(option) => option.tokenize(),
EntryOption::FollowLocation(option) => option.tokenize(), EntryOption::FollowLocation(option) => option.tokenize(),
@ -877,6 +879,48 @@ impl Tokenizable for CaCertificateOption {
} }
} }
impl Tokenizable for ClientCertOption {
fn tokenize(&self) -> Vec<Token> {
let mut tokens: Vec<Token> = vec![];
tokens.append(
&mut self
.line_terminators
.iter()
.flat_map(|e| e.tokenize())
.collect(),
);
tokens.append(&mut self.space0.tokenize());
tokens.push(Token::String("cert".to_string()));
tokens.append(&mut self.space1.tokenize());
tokens.push(Token::Colon(String::from(":")));
tokens.append(&mut self.space2.tokenize());
tokens.append(&mut self.filename.tokenize());
tokens.append(&mut self.line_terminator0.tokenize());
tokens
}
}
impl Tokenizable for ClientKeyOption {
fn tokenize(&self) -> Vec<Token> {
let mut tokens: Vec<Token> = vec![];
tokens.append(
&mut self
.line_terminators
.iter()
.flat_map(|e| e.tokenize())
.collect(),
);
tokens.append(&mut self.space0.tokenize());
tokens.push(Token::String("key".to_string()));
tokens.append(&mut self.space1.tokenize());
tokens.push(Token::Colon(String::from(":")));
tokens.append(&mut self.space2.tokenize());
tokens.append(&mut self.filename.tokenize());
tokens.append(&mut self.line_terminator0.tokenize());
tokens
}
}
impl Tokenizable for CompressedOption { impl Tokenizable for CompressedOption {
fn tokenize(&self) -> Vec<Token> { fn tokenize(&self) -> Vec<Token> {
let mut tokens: Vec<Token> = vec![]; let mut tokens: Vec<Token> = vec![];