use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::provider::ProviderRegistry;
use crate::solver::{
CaptchaSolverChain, ChainConfig, MultiStepCaptchaSolver, OcrCaptchaSolver, SolveConfig,
ThirdPartyCaptchaSolver, ThirdPartyService, TokenCache, VlmCaptchaSolver,
};
const DEFAULT_CACHE_TTL_SECONDS: u64 = 60;
const ENV_CONFIG_PATH: &str = "CAPTCHAFORGE_CONFIG";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub per_solver_timeout_ms: Option<u64>,
pub screenshot_on_failure: Option<bool>,
pub verify_outcome: Option<bool>,
pub cache: CacheConfig,
pub vlm: VlmConfig,
pub third_party: ThirdPartyConfig,
pub solve: SolveOverrides,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheConfig {
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct VlmConfig {
pub endpoint: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ThirdPartyConfig {
pub service: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub poll_interval_ms: Option<u64>,
pub max_polls: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SolveOverrides {
pub checkbox_poll_interval_ms: Option<u64>,
pub checkbox_max_attempts: Option<u32>,
pub token_poll_interval_ms: Option<u64>,
pub token_max_attempts: Option<u32>,
pub audio_button_delay_ms: Option<u64>,
pub audio_submit_delay_ms: Option<u64>,
pub vlm_http_timeout_ms: Option<u64>,
pub client_http_timeout_ms: Option<u64>,
}
impl Config {
pub fn discover() -> anyhow::Result<Self> {
for path in Self::search_paths() {
if path.is_file() {
return Self::load_from_path(&path);
}
}
Ok(Self::default())
}
pub fn search_paths() -> Vec<PathBuf> {
Self::search_paths_with_env(|n| std::env::var(n).ok())
}
pub(crate) fn search_paths_with_env<F>(env: F) -> Vec<PathBuf>
where
F: Fn(&str) -> Option<String>,
{
let mut out = Vec::new();
if let Some(p) = env(ENV_CONFIG_PATH).filter(|s| !s.is_empty()) {
out.push(PathBuf::from(p));
}
out.push(PathBuf::from(".captchaforge.toml"));
out.push(PathBuf::from("captchaforge.toml"));
let xdg = env("XDG_CONFIG_HOME")
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config")));
if let Some(base) = xdg {
out.push(base.join("captchaforge").join("config.toml"));
}
out
}
pub fn load_from_path(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let path = path.as_ref();
let body = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?;
let cfg = Self::from_toml_str(&body)
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
cfg.warn_on_inline_secrets(path);
Ok(cfg)
}
pub fn warn_on_inline_secrets(&self, source: &Path) {
if self
.third_party
.api_key
.as_deref()
.is_some_and(|k| !k.is_empty())
{
tracing::warn!(
config = %source.display(),
"captchaforge: third_party.api_key is set inline. \
prefer the CAPTCHAFORGE_THIRDPARTY_API_KEY env var \
(or a secrets store) so the key doesn't get committed \
to git alongside the config file"
);
}
}
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn chain_config(&self) -> ChainConfig {
let mut c = ChainConfig::default();
if let Some(v) = self.per_solver_timeout_ms {
c.per_solver_timeout_ms = v;
}
if let Some(v) = self.screenshot_on_failure {
c.screenshot_on_failure = v;
}
if let Some(v) = self.verify_outcome {
c.verify_outcome = v;
}
c
}
pub fn solve_config(&self) -> SolveConfig {
let mut c = SolveConfig::default();
let s = &self.solve;
if let Some(v) = s.checkbox_poll_interval_ms {
c.checkbox_poll_interval_ms = v;
}
if let Some(v) = s.checkbox_max_attempts {
c.checkbox_max_attempts = v;
}
if let Some(v) = s.token_poll_interval_ms {
c.token_poll_interval_ms = v;
}
if let Some(v) = s.token_max_attempts {
c.token_max_attempts = v;
}
if let Some(v) = s.audio_button_delay_ms {
c.audio_button_delay_ms = v;
}
if let Some(v) = s.audio_submit_delay_ms {
c.audio_submit_delay_ms = v;
}
if let Some(v) = s.vlm_http_timeout_ms {
c.vlm_http_timeout_ms = v;
}
if let Some(v) = s.client_http_timeout_ms {
c.client_http_timeout_ms = v;
}
c
}
pub fn build_token_cache(&self) -> TokenCache {
let ttl = self.cache.ttl_seconds.unwrap_or(DEFAULT_CACHE_TTL_SECONDS);
TokenCache::with_ttl(std::time::Duration::from_secs(ttl))
}
pub fn build_vlm_solver(&self) -> VlmCaptchaSolver {
let mut s = VlmCaptchaSolver::new();
if let Some(ep) = &self.vlm.endpoint {
s = s.with_endpoint(ep.clone());
}
if let Some(m) = &self.vlm.model {
s = s.with_model(m.clone());
}
s.with_config(self.solve_config())
}
pub fn build_third_party_solver(&self) -> Option<ThirdPartyCaptchaSolver> {
let tp = &self.third_party;
if tp.service.is_none()
&& tp.base_url.is_none()
&& tp.api_key.is_none()
&& tp.poll_interval_ms.is_none()
&& tp.max_polls.is_none()
{
return None;
}
let service = match tp.service.as_deref().unwrap_or("two_captcha") {
"two_captcha" => ThirdPartyService::TwoCaptcha,
"cap_monster" => ThirdPartyService::CapMonster,
"cap_solver" => ThirdPartyService::CapSolver,
"custom" => {
let base_url = tp
.base_url
.as_deref()
.filter(|u| !u.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"third_party.service = \"custom\" requires \
third_party.base_url to be set (got empty / missing)"
)
})
.ok()?;
let parsed = url::Url::parse(base_url).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
tracing::warn!(
scheme = %parsed.scheme(),
"rejecting custom third-party service: non-http(s) scheme"
);
return None;
}
if !host_is_safe_for_outbound(&parsed) {
tracing::warn!(
url = %parsed,
"rejecting custom third-party service: host failed SSRF policy \
(loopback / private / link-local / metadata)"
);
return None;
}
ThirdPartyService::Custom {
base_url: base_url.to_string(),
}
}
other => {
tracing::warn!(
service = %other,
"unrecognised third_party.service, refusing to fall back to \
freeform URL (SSRF protection). Set service = \"custom\" + \
base_url to use a non-built-in endpoint."
);
return None;
}
};
let mut s = match service {
ThirdPartyService::TwoCaptcha => ThirdPartyCaptchaSolver::two_captcha(),
ThirdPartyService::CapMonster => ThirdPartyCaptchaSolver::cap_monster(),
ThirdPartyService::CapSolver => ThirdPartyCaptchaSolver::cap_solver(),
ThirdPartyService::Custom { base_url } => {
ThirdPartyCaptchaSolver::custom_endpoint(base_url)
}
};
if let Some(key) = tp.api_key.as_deref().filter(|k| !k.is_empty()) {
s = s.with_api_key(key);
}
if let Some(v) = tp.poll_interval_ms {
s = s.with_poll_interval_ms(v);
}
if let Some(v) = tp.max_polls {
s = s.with_max_polls(v);
}
Some(s)
}
pub fn build_chain(&self) -> anyhow::Result<CaptchaSolverChain> {
let registry = Arc::new(ProviderRegistry::with_built_in_rules()?);
let mut chain = CaptchaSolverChain::default_chain()
.with_config(self.chain_config())
.with_provider_registry(registry)
.with_token_cache(Arc::new(self.build_token_cache()));
chain.add_solver(MultiStepCaptchaSolver::new());
chain.add_solver(OcrCaptchaSolver::new());
chain.add_solver(self.build_vlm_solver());
if let Some(tp) = self.build_third_party_solver() {
chain.add_solver(tp);
}
Ok(chain)
}
}
fn host_is_safe_for_outbound(parsed: &url::Url) -> bool {
let Some(host) = parsed.host() else {
return false;
};
match host {
url::Host::Ipv4(addr) => {
!(addr.is_loopback()
|| addr.is_private()
|| addr.is_link_local()
|| addr.is_unspecified())
}
url::Host::Ipv6(addr) => {
if let Some(v4) = addr.to_ipv4_mapped() {
if v4.is_loopback() || v4.is_private() || v4.is_link_local() {
return false;
}
}
let seg = addr.segments();
!(addr.is_loopback()
|| addr.is_unspecified()
|| (seg[0] & 0xffc0) == 0xfe80
|| (seg[0] & 0xfe00) == 0xfc00)
}
url::Host::Domain(d) => {
let lower = d.to_ascii_lowercase();
!matches!(
lower.as_str(),
"localhost"
| "ip6-localhost"
| "ip6-loopback"
| "metadata.google.internal"
| "metadata.aws.internal"
)
}
}
}
#[cfg(test)]
#[path = "config/tests.rs"]
mod tests;