pub mod xml;
use std::cell::Cell;
use std::thread;
use std::time::{Duration, Instant};
use crate::config::Profile;
const MIN_SPACING: Duration = Duration::from_millis(3100);
const RETRY_BACKOFF: Duration = Duration::from_secs(5);
pub const READ_ONLY_COMMANDS: &[&str] = &[
"domains.getlist",
"domains.check",
"domains.getregistrarlock",
"domains.getinfo",
"domains.getcontacts",
"domains.gettldlist",
"domains.dns.getlist",
"domains.dns.gethosts",
"domains.transfer.getstatus",
"whoisguard.getlist",
"users.getbalances",
"users.getpricing",
];
pub fn canonical_command(command: &str) -> String {
let stripped = command
.strip_prefix("namecheap.")
.or_else(|| command.strip_prefix("Namecheap."))
.unwrap_or(command);
stripped.to_ascii_lowercase()
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Config(#[from] crate::config::ConfigError),
#[error("transport: {0}")]
Transport(String),
#[error("rate limited by Namecheap ({0}); retry later")]
RateLimited(String),
#[error("Namecheap API error {code}: {message}")]
Api { code: String, message: String },
#[error("unexpected API response: {0}")]
Parse(String),
#[error("{0}")]
Usage(String),
#[error("{0}")]
Policy(String),
}
impl Error {
pub fn exit_code(&self) -> u8 {
match self {
Error::Api { .. } | Error::Parse(_) => 1,
Error::Usage(_) => 2,
Error::Config(_) | Error::Policy(_) => 3,
Error::Transport(_) => 4,
Error::RateLimited(_) => 5,
}
}
pub fn kind(&self) -> &'static str {
match self {
Error::Api { .. } => "api",
Error::Parse(_) => "parse",
Error::Usage(_) => "usage",
Error::Config(_) | Error::Policy(_) => "config",
Error::Transport(_) => "transport",
Error::RateLimited(_) => "rate_limit",
}
}
pub fn code(&self) -> Option<&str> {
match self {
Error::Api { code, .. } => Some(code),
_ => None,
}
}
}
#[derive(Debug)]
pub enum TransportFailure {
Status(u16),
Other(String),
}
pub trait Transport {
fn send(&self, endpoint: &str, params: &[(String, String)])
-> Result<String, TransportFailure>;
}
pub struct HttpTransport {
agent: ureq::Agent,
}
impl HttpTransport {
pub fn new() -> Self {
let config = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.https_only(cfg!(not(debug_assertions)))
.max_redirects(0)
.build();
Self {
agent: config.into(),
}
}
}
impl Default for HttpTransport {
fn default() -> Self {
Self::new()
}
}
impl Transport for HttpTransport {
fn send(
&self,
endpoint: &str,
params: &[(String, String)],
) -> Result<String, TransportFailure> {
let form: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
match self.agent.post(endpoint).send_form(form) {
Ok(resp) if resp.status().is_redirection() => {
Err(TransportFailure::Status(resp.status().as_u16()))
}
Ok(mut resp) => resp
.body_mut()
.read_to_string()
.map_err(|e| TransportFailure::Other(e.to_string())),
Err(ureq::Error::StatusCode(code)) => Err(TransportFailure::Status(code)),
Err(e) => Err(TransportFailure::Other(e.to_string())),
}
}
}
fn acquire_ledger_lock(dir: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(dir.join("spend.lock"))?;
file.lock()?;
Ok(file)
}
fn acquire_throttle_lock(dir: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(dir.join("throttle.lock"))?;
file.lock()?;
Ok(file)
}
fn unix_now_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn redact(msg: &str, key: &str) -> String {
if key.len() < 8 {
return msg.to_owned();
}
let encoded: String = key
.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect();
let out = msg.replace(key, "<redacted>");
if encoded != key {
return out.replace(&encoded, "<redacted>");
}
out
}
pub struct Client<T: Transport> {
transport: T,
profile: Profile,
last_call: Cell<Option<Instant>>,
calls: Cell<u32>,
spacing: Duration,
retry_backoff: Duration,
journal_dir: Option<std::path::PathBuf>,
}
impl<T: Transport> Client<T> {
pub fn new(transport: T, profile: Profile) -> Self {
Self {
transport,
profile,
last_call: Cell::new(None),
calls: Cell::new(0),
spacing: MIN_SPACING,
retry_backoff: RETRY_BACKOFF,
journal_dir: None,
}
}
pub fn set_journal_dir(&mut self, dir: Option<std::path::PathBuf>) {
self.journal_dir = dir;
}
pub fn journal_dir(&self) -> Option<&std::path::Path> {
self.journal_dir.as_deref()
}
pub fn set_timing(&mut self, spacing: Duration, retry_backoff: Duration) {
self.spacing = spacing;
self.retry_backoff = retry_backoff;
}
pub fn profile(&self) -> &Profile {
&self.profile
}
pub fn transport(&self) -> &T {
&self.transport
}
pub fn calls(&self) -> u32 {
self.calls.get()
}
pub fn call(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
let canonical = canonical_command(command);
if !READ_ONLY_COMMANDS.contains(&canonical.as_str()) {
return Err(Error::Policy(format!(
"{command:?} is not a known read-only command; \
mutating commands must use the mutation path"
)));
}
self.dispatch(&canonical, params, true)
}
pub fn require_mutations_permitted(&self) -> Result<(), Error> {
if !self.profile.sandbox && !self.profile.allow_production_mutations {
return Err(Error::Policy(
"mutations against production are disabled; use a sandbox profile \
or set allow_production_mutations = true in this profile"
.into(),
));
}
Ok(())
}
pub fn call_mut(&self, command: &str, params: &[(&str, &str)]) -> Result<String, Error> {
self.require_mutations_permitted()?;
let canonical = canonical_command(command);
let seq = format!("{}-{}", std::process::id(), self.calls.get() + 1);
self.journal_intent(&seq, &canonical, params)?;
let result = self.dispatch(&canonical, params, false);
self.journal_outcome(&seq, &result);
result
}
pub fn reserve_spend(&self, amount: f64, command: &str, domain: &str) -> Result<(), Error> {
let cap = match self.profile.max_daily_spend {
Some(cap) => cap,
None if self.profile.sandbox => {
if self.journal_dir.is_some() {
self.append_spend(amount, command, domain)?;
}
return Ok(());
}
None => {
return Err(Error::Policy(
"purchases against production require max_daily_spend in the profile".into(),
));
}
};
let Some(dir) = &self.journal_dir else {
return Err(Error::Policy(
"max_daily_spend is set but no state directory is available to track it".into(),
));
};
let _lock = acquire_ledger_lock(dir).map_err(|e| {
Error::Policy(format!(
"cannot lock the spend ledger ({e}); refusing to purchase"
))
})?;
let spent = self.spend_last_24h().map_err(|e| {
Error::Policy(format!(
"cannot read the spend ledger ({e}); refusing to purchase"
))
})?;
if spent + amount > cap {
return Err(Error::Policy(format!(
"daily spend cap would be exceeded: {spent:.2} spent in the last 24h \
+ {amount:.2} requested > max_daily_spend {cap:.2}"
)));
}
self.append_spend(amount, command, domain)
}
fn append_spend(&self, amount: f64, command: &str, domain: &str) -> Result<(), Error> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let Some(dir) = &self.journal_dir else {
return Ok(());
};
let write = || -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(dir.join("spend.jsonl"))?;
writeln!(
file,
"{}",
serde_json::json!({
"ts": unix_now(),
"profile": self.profile.name,
"sandbox": self.profile.sandbox,
"command": command,
"domain": domain,
"amount": amount,
})
)?;
file.sync_all()
};
write().map_err(|e| {
Error::Policy(format!(
"cannot record the spend reservation ({e}); refusing to purchase"
))
})
}
fn spend_last_24h(&self) -> std::io::Result<f64> {
let Some(dir) = &self.journal_dir else {
return Ok(0.0);
};
let path = dir.join("spend.jsonl");
if !path.exists() {
return Ok(0.0);
}
let cutoff = unix_now().saturating_sub(24 * 60 * 60);
let mut total = 0.0;
for line in std::fs::read_to_string(&path)?.lines() {
let Ok(rec) = serde_json::from_str::<serde_json::Value>(line) else {
return Err(std::io::Error::other("corrupt spend ledger line"));
};
let ts = rec["ts"].as_u64().unwrap_or(0);
let same_profile = rec["profile"].as_str() == Some(self.profile.name.as_str());
if ts >= cutoff && same_profile {
total += rec["amount"].as_f64().unwrap_or(0.0);
}
}
Ok(total)
}
pub fn journal_note(&self, command: &str, data: serde_json::Value) {
let _ = self.journal_append(
serde_json::json!({
"ts": unix_now(),
"kind": "note",
"profile": self.profile.name,
"sandbox": self.profile.sandbox,
"command": command,
"data": data,
}),
false,
);
}
fn journal_intent(
&self,
seq: &str,
command: &str,
params: &[(&str, &str)],
) -> Result<(), Error> {
if self.journal_dir.is_none() {
return Ok(());
}
let params: serde_json::Map<String, serde_json::Value> = params
.iter()
.map(|(k, v)| ((*k).to_owned(), serde_json::Value::from(*v)))
.collect();
self.journal_append(
serde_json::json!({
"ts": unix_now(),
"seq": seq,
"kind": "intent",
"profile": self.profile.name,
"sandbox": self.profile.sandbox,
"command": command,
"params": params,
}),
true,
)
.map_err(|e| {
Error::Policy(format!(
"cannot record mutation intent in the journal ({e}); refusing to mutate"
))
})
}
fn journal_outcome(&self, seq: &str, result: &Result<String, Error>) {
let record = match result {
Ok(body) => serde_json::json!({
"ts": unix_now(),
"seq": seq,
"kind": "outcome",
"ok": true,
"body_excerpt": body.chars().take(500).collect::<String>(),
}),
Err(e) => serde_json::json!({
"ts": unix_now(),
"seq": seq,
"kind": "outcome",
"ok": false,
"error": e.to_string(),
}),
};
let _ = self.journal_append(record, false);
}
fn journal_append(&self, record: serde_json::Value, fsync: bool) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let Some(dir) = &self.journal_dir else {
return Ok(());
};
std::fs::create_dir_all(dir)?;
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(dir.join("mutations.jsonl"))?;
writeln!(file, "{record}")?;
if fsync {
file.sync_all()?;
}
Ok(())
}
fn dispatch(
&self,
canonical: &str,
params: &[(&str, &str)],
retry: bool,
) -> Result<String, Error> {
let mut all: Vec<(String, String)> = vec![
("ApiUser".into(), self.profile.api_user.clone()),
("ApiKey".into(), self.profile.api_key.expose().to_owned()),
("UserName".into(), self.profile.username.clone()),
("Command".into(), format!("namecheap.{canonical}")),
("ClientIp".into(), self.profile.client_ip.clone()),
];
all.extend(
params
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
);
self.calls.set(self.calls.get() + 1);
self.throttle();
let mut attempt = self.transport.send(self.profile.endpoint(), &all);
if retry
&& matches!(attempt, Err(TransportFailure::Status(code)) if code == 429 || code >= 500)
{
thread::sleep(self.retry_backoff);
self.throttle();
attempt = self.transport.send(self.profile.endpoint(), &all);
}
match attempt {
Ok(body) => Ok(body),
Err(TransportFailure::Status(429)) => Err(Error::RateLimited("HTTP 429".into())),
Err(TransportFailure::Status(405)) => Err(Error::RateLimited(
"HTTP 405 — the rate-limit shape observed live".into(),
)),
Err(TransportFailure::Status(code)) => {
Err(Error::Transport(format!("HTTP status {code}")))
}
Err(TransportFailure::Other(msg)) => Err(Error::Transport(redact(
&msg,
self.profile.api_key.expose(),
))),
}
}
fn throttle(&self) {
if let Some(dir) = &self.journal_dir
&& self.spacing > Duration::ZERO
&& let Ok(lock) = acquire_throttle_lock(dir)
{
use std::io::{Read, Seek, Write};
let mut file = lock;
let mut buf = String::new();
let _ = (&file).read_to_string(&mut buf);
let now_ms = unix_now_millis();
if let Ok(prev_ms) = buf.trim().parse::<u128>() {
let elapsed = now_ms.saturating_sub(prev_ms);
let spacing_ms = self.spacing.as_millis();
if elapsed < spacing_ms {
thread::sleep(Duration::from_millis((spacing_ms - elapsed) as u64));
}
}
let _ = file.set_len(0);
let _ = file.seek(std::io::SeekFrom::Start(0));
let _ = write!(file, "{}", unix_now_millis());
} else if let Some(prev) = self.last_call.get()
&& prev.elapsed() < self.spacing
{
thread::sleep(self.spacing - prev.elapsed());
}
self.last_call.set(Some(Instant::now()));
}
}