use std::path::PathBuf;
use clap::{Args as ClapArgs, Subcommand, ValueEnum};
use comfy_table::Cell;
use quicknode_sdk::admin::{
CreateDomainMaskRequest, CreateIpRequest, CreateJwtRequest,
CreateOrUpdateIpCustomHeaderRequest, CreateReferrerRequest, CreateRequestFilterRequest,
SecurityOptionsUpdate, UpdateRequestFilterRequest, UpdateSecurityOptionsRequest,
};
use serde::Serialize;
use crate::confirm::confirm_mild;
use crate::context::Ctx;
use crate::errors::CliError;
use crate::output::{bool_cell, new_table, opt_cell, set_header_bold, write_table, Render};
use crate::retry::retrying;
#[derive(Debug, Subcommand)]
pub enum SecurityCmd {
Show {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
},
Options {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
},
SetOptions(SetOptionsArgs),
#[command(subcommand)]
Token(TokenCmd),
#[command(subcommand)]
Referrer(ReferrerCmd),
#[command(subcommand)]
Ip(IpCmd),
#[command(subcommand)]
Jwt(JwtCmd),
#[command(subcommand)]
DomainMask(DomainMaskCmd),
#[command(subcommand)]
RequestFilter(RequestFilterCmd),
#[command(subcommand)]
IpHeader(IpHeaderCmd),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Toggle {
Enabled,
Disabled,
}
impl Toggle {
fn as_str(self) -> &'static str {
match self {
Toggle::Enabled => "enabled",
Toggle::Disabled => "disabled",
}
}
}
#[derive(Debug, ClapArgs)]
pub struct SetOptionsArgs {
#[arg(value_name = "ENDPOINT_ID")]
pub id: String,
#[arg(long, value_enum)]
pub tokens: Option<Toggle>,
#[arg(long, value_enum)]
pub referrers: Option<Toggle>,
#[arg(long, value_enum)]
pub jwts: Option<Toggle>,
#[arg(long, value_enum)]
pub ips: Option<Toggle>,
#[arg(long, value_enum)]
pub domain_masks: Option<Toggle>,
#[arg(long, value_enum)]
pub hsts: Option<Toggle>,
#[arg(long, value_enum)]
pub cors: Option<Toggle>,
#[arg(long, value_enum)]
pub request_filters: Option<Toggle>,
#[arg(long, value_enum)]
pub ip_custom_header: Option<Toggle>,
}
#[derive(Debug, Subcommand)]
pub enum TokenCmd {
Create {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
},
Delete {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
token_id: String,
},
}
#[derive(Debug, Subcommand)]
pub enum ReferrerCmd {
Add {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
referrer: String,
},
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
referrer_id: String,
},
}
#[derive(Debug, Subcommand)]
pub enum IpCmd {
Add {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
ip: String,
},
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
ip_id: String,
},
}
#[derive(Debug, Subcommand)]
pub enum JwtCmd {
Add(JwtAddArgs),
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
jwt_id: String,
},
}
#[derive(Debug, ClapArgs)]
pub struct JwtAddArgs {
#[arg(value_name = "ENDPOINT_ID")]
pub id: String,
#[arg(long)]
pub public_key: Option<String>,
#[arg(long)]
pub public_key_file: Option<PathBuf>,
#[arg(long)]
pub kid: Option<String>,
#[arg(long)]
pub name: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum DomainMaskCmd {
Add {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
domain: String,
},
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
domain_mask_id: String,
},
}
#[derive(Debug, Subcommand)]
pub enum RequestFilterCmd {
Create(RequestFilterCreateArgs),
Update(RequestFilterUpdateArgs),
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
request_filter_id: String,
},
}
#[derive(Debug, ClapArgs)]
pub struct RequestFilterCreateArgs {
#[arg(value_name = "ENDPOINT_ID")]
pub id: String,
#[arg(long = "method")]
pub methods: Vec<String>,
#[arg(long = "methods", value_delimiter = ',')]
pub methods_csv: Vec<String>,
}
#[derive(Debug, ClapArgs)]
pub struct RequestFilterUpdateArgs {
#[arg(value_name = "ENDPOINT_ID")]
pub id: String,
pub request_filter_id: String,
#[arg(long = "method")]
pub methods: Vec<String>,
#[arg(long = "methods", value_delimiter = ',')]
pub methods_csv: Vec<String>,
}
#[derive(Debug, Subcommand)]
pub enum IpHeaderCmd {
Set {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
header_name: String,
},
Remove {
#[arg(value_name = "ENDPOINT_ID")]
id: String,
},
}
pub async fn run(cmd: SecurityCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
SecurityCmd::Show { id } => show(&id, ctx).await,
SecurityCmd::Options { id } => options_show(&id, ctx).await,
SecurityCmd::SetOptions(a) => set_options(a, ctx).await,
SecurityCmd::Token(c) => token(c, ctx).await,
SecurityCmd::Referrer(c) => referrer(c, ctx).await,
SecurityCmd::Ip(c) => ip(c, ctx).await,
SecurityCmd::Jwt(c) => jwt(c, ctx).await,
SecurityCmd::DomainMask(c) => domain_mask(c, ctx).await,
SecurityCmd::RequestFilter(c) => request_filter(c, ctx).await,
SecurityCmd::IpHeader(c) => ip_header(c, ctx).await,
}
}
async fn show(id: &str, ctx: Ctx) -> Result<(), CliError> {
let resp = retrying(ctx.global.retries, || {
ctx.sdk.admin.get_endpoint_security(id)
})
.await?;
crate::output::emit(&ctx.out, &SecurityShowView(resp))
}
async fn options_show(id: &str, ctx: Ctx) -> Result<(), CliError> {
let resp = retrying(ctx.global.retries, || {
ctx.sdk.admin.get_security_options(id)
})
.await?;
crate::output::emit(&ctx.out, &SecurityOptionsView(resp))
}
async fn set_options(a: SetOptionsArgs, ctx: Ctx) -> Result<(), CliError> {
if a.tokens.is_none()
&& a.referrers.is_none()
&& a.jwts.is_none()
&& a.ips.is_none()
&& a.domain_masks.is_none()
&& a.hsts.is_none()
&& a.cors.is_none()
&& a.request_filters.is_none()
&& a.ip_custom_header.is_none()
{
return Err(CliError::Arg(
"'endpoint security set-options' requires at least one of: \
--tokens, --referrers, --jwts, --ips, --domain-masks, --hsts, \
--cors, --request-filters, --ip-custom-header."
.into(),
));
}
let options = SecurityOptionsUpdate {
tokens: a.tokens.map(|t| t.as_str().to_string()),
referrers: a.referrers.map(|t| t.as_str().to_string()),
jwts: a.jwts.map(|t| t.as_str().to_string()),
ips: a.ips.map(|t| t.as_str().to_string()),
domain_masks: a.domain_masks.map(|t| t.as_str().to_string()),
hsts: a.hsts.map(|t| t.as_str().to_string()),
cors: a.cors.map(|t| t.as_str().to_string()),
request_filters: a.request_filters.map(|t| t.as_str().to_string()),
ip_custom_header: a.ip_custom_header.map(|t| t.as_str().to_string()),
};
let req = UpdateSecurityOptionsRequest { options };
let resp = ctx.sdk.admin.update_security_options(&a.id, &req).await?;
ctx.out
.note(&format!("✓ Updated security options on {}", a.id));
crate::output::emit(&ctx.out, &SecurityOptionsListView(resp.data))
}
async fn warn_if_option_disabled(ctx: &Ctx, id: &str, option: &str, flag: &str, item_desc: &str) {
if ctx.out.quiet {
return;
}
let Ok(resp) = ctx.sdk.admin.get_security_options(id).await else {
return;
};
if resp
.data
.iter()
.any(|o| o.option == option && o.status == "disabled")
{
ctx.out.warn(&format!(
"⚠ The '{option}' security option is disabled on {id} —\n \
{item_desc} will have no effect until you enable it:\n \
qn endpoint security set-options --{flag} enabled {id}"
));
}
}
async fn token(cmd: TokenCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
TokenCmd::Create { id } => {
ctx.sdk.admin.create_token(&id).await?;
ctx.out.note(&format!("✓ Created token on {id}"));
warn_if_option_disabled(&ctx, &id, "tokens", "tokens", "this token").await;
}
TokenCmd::Delete { id, token_id } => {
confirm_mild(
&ctx,
&format!(
"Delete token {token_id} on {id}? Clients authenticating with it lose access"
),
)?;
ctx.sdk.admin.delete_token(&id, &token_id).await?;
ctx.out.note(&format!("✓ Deleted token {token_id} on {id}"));
}
}
Ok(())
}
async fn referrer(cmd: ReferrerCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
ReferrerCmd::Add { id, referrer } => {
let req = CreateReferrerRequest {
referrer: Some(referrer.clone()),
};
ctx.sdk.admin.create_referrer(&id, &req).await?;
ctx.out
.note(&format!("✓ Whitelisted referrer {referrer:?} on {id}"));
warn_if_option_disabled(&ctx, &id, "referrers", "referrers", "this referrer").await;
}
ReferrerCmd::Remove { id, referrer_id } => {
confirm_mild(
&ctx,
&format!("Remove referrer {referrer_id} from endpoint {id}'s whitelist?"),
)?;
ctx.sdk.admin.delete_referrer(&id, &referrer_id).await?;
ctx.out
.note(&format!("✓ Removed referrer {referrer_id} on {id}"));
}
}
Ok(())
}
async fn ip(cmd: IpCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
IpCmd::Add { id, ip } => {
let req = CreateIpRequest {
ip: Some(ip.clone()),
};
ctx.sdk.admin.create_ip(&id, &req).await?;
ctx.out.note(&format!("✓ Whitelisted IP {ip} on {id}"));
warn_if_option_disabled(&ctx, &id, "ips", "ips", "this IP").await;
}
IpCmd::Remove { id, ip_id } => {
confirm_mild(
&ctx,
&format!("Remove IP {ip_id} from endpoint {id}'s whitelist?"),
)?;
ctx.sdk.admin.delete_ip(&id, &ip_id).await?;
ctx.out.note(&format!("✓ Removed IP {ip_id} on {id}"));
}
}
Ok(())
}
async fn jwt(cmd: JwtCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
JwtCmd::Add(a) => {
let public_key = match (a.public_key, a.public_key_file) {
(Some(s), None) => Some(s),
(None, Some(p)) => Some(std::fs::read_to_string(&p)?),
(None, None) => {
return Err(CliError::Arg(
"supply --public-key or --public-key-file".to_string(),
));
}
(Some(_), Some(_)) => {
return Err(CliError::Arg(
"supply only one of --public-key or --public-key-file".to_string(),
));
}
};
let req = CreateJwtRequest {
public_key,
kid: a.kid,
name: a.name,
};
ctx.sdk.admin.create_jwt(&a.id, &req).await?;
ctx.out.note(&format!("✓ Added JWT on {}", a.id));
warn_if_option_disabled(&ctx, &a.id, "jwts", "jwts", "this JWT").await;
}
JwtCmd::Remove { id, jwt_id } => {
confirm_mild(&ctx, &format!("Remove JWT {jwt_id} from endpoint {id}?"))?;
ctx.sdk.admin.delete_jwt(&id, &jwt_id).await?;
ctx.out.note(&format!("✓ Removed JWT {jwt_id} on {id}"));
}
}
Ok(())
}
async fn domain_mask(cmd: DomainMaskCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
DomainMaskCmd::Add { id, domain } => {
let req = CreateDomainMaskRequest {
domain_mask: Some(domain.clone()),
};
ctx.sdk.admin.create_domain_mask(&id, &req).await?;
ctx.out
.note(&format!("✓ Added domain mask {domain:?} on {id}"));
warn_if_option_disabled(&ctx, &id, "domainMasks", "domain-masks", "this domain mask")
.await;
}
DomainMaskCmd::Remove { id, domain_mask_id } => {
confirm_mild(
&ctx,
&format!("Remove domain mask {domain_mask_id} from endpoint {id}?"),
)?;
ctx.sdk
.admin
.delete_domain_mask(&id, &domain_mask_id)
.await?;
ctx.out
.note(&format!("✓ Removed domain mask {domain_mask_id} on {id}"));
}
}
Ok(())
}
async fn request_filter(cmd: RequestFilterCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
RequestFilterCmd::Create(a) => {
let mut methods = a.methods;
methods.extend(a.methods_csv);
if methods.is_empty() {
return Err(CliError::Arg("supply at least one --method".to_string()));
}
let req = CreateRequestFilterRequest {
method: Some(methods),
};
let resp = ctx.sdk.admin.create_request_filter(&a.id, &req).await?;
let d = resp.data.as_ref().ok_or_else(|| {
CliError::Format("API returned success but no data; nothing was created".into())
})?;
ctx.out
.note(&format!("✓ Created request filter {} on {}", d.id, a.id));
warn_if_option_disabled(
&ctx,
&a.id,
"requestFilters",
"request-filters",
"this request filter",
)
.await;
}
RequestFilterCmd::Update(a) => {
let mut methods = a.methods;
methods.extend(a.methods_csv);
let req = UpdateRequestFilterRequest {
method: Some(methods),
};
ctx.sdk
.admin
.update_request_filter(&a.id, &a.request_filter_id, &req)
.await?;
ctx.out.note(&format!(
"✓ Updated request filter {} on {}",
a.request_filter_id, a.id
));
}
RequestFilterCmd::Remove {
id,
request_filter_id,
} => {
confirm_mild(
&ctx,
&format!("Remove request filter {request_filter_id} from endpoint {id}?"),
)?;
ctx.sdk
.admin
.delete_request_filter(&id, &request_filter_id)
.await?;
ctx.out.note(&format!(
"✓ Removed request filter {request_filter_id} on {id}"
));
}
}
Ok(())
}
async fn ip_header(cmd: IpHeaderCmd, ctx: Ctx) -> Result<(), CliError> {
match cmd {
IpHeaderCmd::Set { id, header_name } => {
let req = CreateOrUpdateIpCustomHeaderRequest {
header_name: header_name.clone(),
};
ctx.sdk
.admin
.create_or_update_ip_custom_header(&id, &req)
.await?;
ctx.out
.note(&format!("✓ Set IP header {header_name:?} on {id}"));
warn_if_option_disabled(
&ctx,
&id,
"ipCustomHeader",
"ip-custom-header",
"this header",
)
.await;
}
IpHeaderCmd::Remove { id } => {
confirm_mild(
&ctx,
&format!("Remove the custom IP header configuration on endpoint {id}?"),
)?;
ctx.sdk.admin.delete_ip_custom_header(&id).await?;
ctx.out.note(&format!("✓ Removed IP header config on {id}"));
}
}
Ok(())
}
#[derive(Serialize)]
struct SecurityShowView(quicknode_sdk::admin::GetEndpointSecurityResponse);
impl Render for SecurityShowView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let data = match &self.0.data {
Some(d) => d,
None => {
writeln!(w, "(no security data)")?;
return Ok(());
}
};
let opts = data.options.as_ref();
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["OPTION", "ENABLED"]);
t.add_row(vec![
Cell::new("tokens"),
bool_cell(opts.and_then(|o| o.tokens)),
]);
t.add_row(vec![
Cell::new("jwts"),
bool_cell(opts.and_then(|o| o.jwts)),
]);
t.add_row(vec![
Cell::new("domain_masks"),
bool_cell(opts.and_then(|o| o.domain_masks)),
]);
t.add_row(vec![Cell::new("ips"), bool_cell(opts.and_then(|o| o.ips))]);
t.add_row(vec![
Cell::new("referrers"),
bool_cell(opts.and_then(|o| o.referrers)),
]);
t.add_row(vec![
Cell::new("request_filters"),
bool_cell(opts.and_then(|o| o.request_filters)),
]);
let ip_header = opts
.and_then(|o| o.ip_custom_header.as_ref())
.and_then(|h| h.value.clone());
t.add_row(vec![Cell::new("ip_custom_header"), opt_cell(&ip_header)]);
write_table(w, &t)?;
security_item_sections(w, ctx, data)
}
}
pub(crate) fn security_item_sections(
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
data: &quicknode_sdk::admin::EndpointSecurity,
) -> std::io::Result<()> {
let section = |w: &mut dyn std::io::Write,
title: &str,
headers: Vec<&str>,
rows: Vec<Vec<Cell>>|
-> std::io::Result<()> {
if rows.is_empty() {
return Ok(());
}
writeln!(w)?;
writeln!(w, "{} ({})", title, rows.len())?;
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, headers);
for row in rows {
t.add_row(row);
}
write_table(w, &t)
};
let tokens = data.tokens.as_deref().unwrap_or(&[]);
section(
w,
"TOKENS",
vec!["ID", "TOKEN"],
tokens
.iter()
.map(|t| vec![Cell::new(&t.id), Cell::new(&t.token)])
.collect(),
)?;
let jwts = data.jwts.as_deref().unwrap_or(&[]);
section(
w,
"JWTS",
vec!["ID", "NAME", "KID"],
jwts.iter()
.map(|j| vec![Cell::new(&j.id), Cell::new(&j.name), Cell::new(&j.kid)])
.collect(),
)?;
let referrers = data.referrers.as_deref().unwrap_or(&[]);
section(
w,
"REFERRERS",
vec!["ID", "REFERRER"],
referrers
.iter()
.map(|r| vec![Cell::new(&r.id), opt_cell(&r.referrer)])
.collect(),
)?;
let masks = data.domain_masks.as_deref().unwrap_or(&[]);
section(
w,
"DOMAIN_MASKS",
vec!["ID", "DOMAIN"],
masks
.iter()
.map(|d| vec![Cell::new(&d.id), Cell::new(&d.domain)])
.collect(),
)?;
let ips = data.ips.as_deref().unwrap_or(&[]);
section(
w,
"IPS",
vec!["ID", "IP"],
ips.iter()
.map(|i| vec![Cell::new(&i.id), Cell::new(&i.ip)])
.collect(),
)?;
let filters = data.request_filters.as_deref().unwrap_or(&[]);
section(
w,
"REQUEST_FILTERS",
vec!["ID", "METHODS"],
filters
.iter()
.map(|f| vec![Cell::new(&f.id), Cell::new(f.method.join(", "))])
.collect(),
)
}
#[derive(Serialize)]
struct SecurityOptionsView(quicknode_sdk::admin::GetSecurityOptionsResponse);
impl Render for SecurityOptionsView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["OPTION", "STATUS", "VALUE"]);
for o in &self.0.data {
t.add_row(vec![
Cell::new(&o.option),
Cell::new(&o.status),
opt_cell(&o.value),
]);
}
write_table(w, &t)
}
}
#[derive(Serialize)]
struct SecurityOptionsListView(Vec<quicknode_sdk::admin::SecurityOption>);
impl Render for SecurityOptionsListView {
fn render_table(
&self,
w: &mut dyn std::io::Write,
ctx: &crate::output::OutputCtx,
) -> std::io::Result<()> {
let mut t = new_table(ctx);
set_header_bold(&mut t, ctx, vec!["OPTION", "STATUS", "VALUE"]);
for o in &self.0 {
t.add_row(vec![
Cell::new(&o.option),
Cell::new(&o.status),
opt_cell(&o.value),
]);
}
write_table(w, &t)
}
}