mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-08 04:08:46 +02:00
refactor: cleanup
This commit is contained in:
Generated
+1
@@ -1821,6 +1821,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"gtk",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
|
||||
@@ -116,6 +116,10 @@ sys-locale = "0.3"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
# Reading the desktop's titlebar button layout for the in-app window controls.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6.4"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
@@ -3033,7 +3033,7 @@ fn cookie_bot_eligible_profile(
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile)
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
|
||||
Ok(profile)
|
||||
}
|
||||
@@ -3315,10 +3315,7 @@ async fn list_cookie_bot_runs(
|
||||
async fn start_cookie_bot_run(
|
||||
Json(request): Json<StartCookieBotRunRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
@@ -4234,14 +4231,22 @@ mod tests {
|
||||
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
|
||||
local_only.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&local_only,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a profile with no cloud copy has nothing for a host to open"
|
||||
);
|
||||
|
||||
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
|
||||
encrypted.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&encrypted,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a host cannot decrypt a profile whose key never leaves this machine"
|
||||
);
|
||||
|
||||
@@ -4249,13 +4254,21 @@ mod tests {
|
||||
datacenter_egress.proxy_id = None;
|
||||
datacenter_egress.vpn_id = None;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&datacenter_egress,
|
||||
&crate::remote_exit::ExitReachability::None
|
||||
)
|
||||
.is_err(),
|
||||
"hours of traffic from a hosting ASN damages the identity being warmed"
|
||||
);
|
||||
|
||||
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
|
||||
eligible.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
|
||||
assert!(crate::cookie_bot::bot_precondition(
|
||||
&eligible,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -819,6 +819,21 @@ impl CloudAuthManager {
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
/// Whether this account may run the nightly Cookie Bot.
|
||||
///
|
||||
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
|
||||
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
|
||||
/// so gating the bot on automation refused a Solo customer the one feature
|
||||
/// their plan is sold on, and answered 402 while their scheduled runs kept
|
||||
/// working server-side.
|
||||
pub async fn can_use_cookie_bot(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cookie_bot)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
|
||||
+642
-11
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
|
||||
conflict: cloud_errors::UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Failure codes for the user-template routes.
|
||||
///
|
||||
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
|
||||
/// template that was deleted (possibly from another device), not an unenrolled
|
||||
/// profile, and a 409 is a name the user already used, not a teammate's
|
||||
/// enrolment. Sharing the schedule set would have told someone renaming a site
|
||||
/// list that a colleague already warms this profile.
|
||||
const TEMPLATE_CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
|
||||
forbidden: "COOKIE_BOT_NOT_ENTITLED",
|
||||
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
|
||||
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
|
||||
};
|
||||
|
||||
/// Every cookie-bot call fails as a code the frontend can translate.
|
||||
///
|
||||
/// There is no `Other(String)` carrying backend English: a raw message reaches
|
||||
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
|
||||
// One place for every request and response shape, so a backend contract change
|
||||
// is a single edit here rather than a hunt through call sites.
|
||||
|
||||
/// One time-of-day an enrolment fires, on a set of local weekdays.
|
||||
///
|
||||
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
|
||||
/// rebuilds that list on every keystroke in the enrolment form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSlot {
|
||||
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
|
||||
pub days_mask: u8,
|
||||
/// Minutes past local midnight, in the schedule's timezone.
|
||||
pub run_at_minute: u16,
|
||||
}
|
||||
|
||||
/// A profile enrolled in the nightly bot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSchedule {
|
||||
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
|
||||
pub profile_name: String,
|
||||
pub platform: String,
|
||||
pub enabled: bool,
|
||||
/// Minutes past local midnight the run is anchored to.
|
||||
/// Minutes past local midnight the FIRST slot is anchored to. The server
|
||||
/// mirrors `slots[0]` onto this pair on every write.
|
||||
pub run_at_minute: u16,
|
||||
/// Bitmask of local weekdays, bit 0 = Monday.
|
||||
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
|
||||
pub days_mask: u8,
|
||||
/// Every time-of-day this enrolment fires.
|
||||
///
|
||||
/// `default` rather than required because a server older than multi-slot
|
||||
/// scheduling sends only the mirrored pair above, and a decode failure there
|
||||
/// would blank the whole Cookie Bot surface rather than show one time instead
|
||||
/// of several. Callers must therefore fall back to the pair when this is
|
||||
/// empty — never treat an empty list as "fires at no time".
|
||||
#[serde(default)]
|
||||
pub slots: Vec<CookieBotSlot>,
|
||||
pub timezone: String,
|
||||
/// Server-issued preset id. Opaque here — what it expands to is infra's.
|
||||
pub preset: String,
|
||||
/// The template the sites came from, or `None` for the user's own list.
|
||||
///
|
||||
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
|
||||
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
|
||||
/// is provenance only — those sites were copied onto the enrolment and are
|
||||
/// present below.
|
||||
#[serde(default)]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
@@ -120,6 +164,11 @@ pub struct CookieBotSchedule {
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is one a leased fleet host could dial. Defaults to false
|
||||
/// on an older server that does not send it, which reads as "not reachable"
|
||||
/// and is the safe direction.
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
#[serde(default)]
|
||||
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
|
||||
pub enabled: bool,
|
||||
pub run_at_minute: u16,
|
||||
pub days_mask: u8,
|
||||
/// The whole calendar, when the caller has one.
|
||||
///
|
||||
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
|
||||
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
|
||||
/// present-but-empty one, and `null` takes the refusing branch. Serialising
|
||||
/// `None` as null would 400 every write from a single-slot form.
|
||||
///
|
||||
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
|
||||
/// predates multi-slot stores the first time rather than nothing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slots: Option<Vec<CookieBotSlot>>,
|
||||
pub timezone: String,
|
||||
pub preset: String,
|
||||
/// A browsing template instead of a typed site list.
|
||||
///
|
||||
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
|
||||
/// carrying both, because merging a curated persona with the user's own list
|
||||
/// produces neither. A caller naming a template sends `sites: []`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A server-owned browsing template: a named answer to "what is this profile
|
||||
/// for", which the user picks INSTEAD of typing a site list.
|
||||
///
|
||||
/// Carries no URLs, and must not gain any. The pool a template draws from is
|
||||
/// server-side for the same reason a preset's browsing model is: a published
|
||||
/// list is one a retailer can filter, and each profile is given its own sample
|
||||
/// so the template never becomes a fleet-wide fingerprint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotTemplate {
|
||||
pub id: String,
|
||||
/// How many sites this template browses. Not which.
|
||||
#[serde(default)]
|
||||
pub site_count: u32,
|
||||
/// Server-supplied English label and blurb, present only so a template added
|
||||
/// after this build still renders. The UI prefers its own `t()` key for an id
|
||||
/// it recognises.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The bounds the schedule routes actually enforce, as this build reads them.
|
||||
///
|
||||
/// Every field is optional because a server that predates `limits` sends none
|
||||
/// of them, and a client that read a missing bound as `0` would refuse every
|
||||
/// value the form can produce. Only the bounds the desktop acts on are decoded
|
||||
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
|
||||
/// so adding a field here is what makes one reachable from TypeScript.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotLimits {
|
||||
#[serde(default)]
|
||||
pub min_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub min_sites: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_sites: Option<u32>,
|
||||
/// Most entries a calendar may carry.
|
||||
#[serde(default)]
|
||||
pub max_slots: Option<u32>,
|
||||
/// Longest name a saved site list may be given.
|
||||
#[serde(default)]
|
||||
pub max_template_name_length: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotPresetList {
|
||||
#[serde(default)]
|
||||
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
|
||||
/// preference.
|
||||
#[serde(default)]
|
||||
pub default_preset: Option<String>,
|
||||
/// The curated templates on offer. Served beside the presets so a template
|
||||
/// added server-side appears without a desktop release.
|
||||
#[serde(default)]
|
||||
pub templates: Vec<CookieBotTemplate>,
|
||||
/// The server's own bounds, when it publishes them. The desktop mirrors a
|
||||
/// copy for offline form validation; these win where they disagree.
|
||||
#[serde(default)]
|
||||
pub limits: Option<CookieBotLimits>,
|
||||
}
|
||||
|
||||
/// One of the caller's OWN saved site lists.
|
||||
///
|
||||
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
|
||||
/// and there is nothing to withhold. Applying one copies the sites onto the
|
||||
/// enrolment, so a list edited later does not silently change what an existing
|
||||
/// enrolment browses until it is saved again.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotUserTemplate {
|
||||
/// Already carries the `user:` prefix: this id's job is to be pasted into a
|
||||
/// schedule's `template_id`, and assembling that convention on the client is
|
||||
/// how the two kinds of template get confused.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
|
||||
/// the client cannot see — but a profile that can never qualify should never
|
||||
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
|
||||
/// `{"code":…}` string a Tauri command surfaces directly.
|
||||
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
pub fn bot_precondition(
|
||||
profile: &BrowserProfile,
|
||||
exit: &crate::remote_exit::ExitReachability,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
// The host materialises the profile by pulling it from donut-sync. A
|
||||
// local-only profile has nothing there, so there is no path to a run.
|
||||
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
// than not warming it at all.
|
||||
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
|
||||
}
|
||||
// ...and the exit has to be one the leased host can reach. The profile and its
|
||||
// proxy record are pulled onto the fleet with no address rewriting, so
|
||||
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
|
||||
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
|
||||
// run fails an hour has been leased and billed.
|
||||
//
|
||||
// Taken as an ARGUMENT rather than resolved here, for the same reason
|
||||
// `ProfileState` is required rather than defaulted: resolving it needs the
|
||||
// proxy and VPN stores, and a function that reaches into those globals is one
|
||||
// no test can set up and every caller silently depends on. `exit_reachability`
|
||||
// is the one place that resolution happens; this stays a pure predicate over
|
||||
// facts it is handed.
|
||||
if !exit.is_remote() {
|
||||
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
|
||||
// A VPN is an exit node just as much as a proxy is; the server only asks
|
||||
// whether the traffic leaves through something the user brought.
|
||||
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
|
||||
// ...and, separately, whether anyone OTHER than this machine could use it.
|
||||
// `has_proxy` answers "did the user bring an exit"; this answers "is that
|
||||
// exit an address a leased host can dial". They disagree for every local
|
||||
// proxy, which is the case that used to be accepted and then fail on the
|
||||
// fleet. See `remote_exit`.
|
||||
proxy_remote_reachable: exit_reachability(profile).is_remote(),
|
||||
// Always false: this data model has no mobile/touch profile. `resolved_os`
|
||||
// yields only windows, macos or linux, and `bot_precondition` already
|
||||
// refuses everything but the first two. Reported rather than omitted so the
|
||||
@@ -533,16 +700,69 @@ pub struct ProfileState {
|
||||
pub sync_enabled: bool,
|
||||
pub encrypted_sync: bool,
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is an address a leased fleet host can dial.
|
||||
pub proxy_remote_reachable: bool,
|
||||
pub touch_fingerprint: bool,
|
||||
pub sticky_exit: bool,
|
||||
}
|
||||
|
||||
/// Whether this profile's exit could be used from a host that is not this one.
|
||||
///
|
||||
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
|
||||
/// do this, because it never sees a proxy record until sync has uploaded one and
|
||||
/// even then would have to re-derive what the browser will actually dial.
|
||||
///
|
||||
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
|
||||
/// what the browser is pointed at, and it is the address the fleet has to reach.
|
||||
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
|
||||
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
|
||||
|
||||
if let Some(proxy_id) = profile.proxy_id.as_deref() {
|
||||
let stored = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == proxy_id);
|
||||
return match stored {
|
||||
Some(proxy) => classify_proxy(&proxy.proxy_settings),
|
||||
// Referenced but missing. Fail closed: a dangling id is not evidence of a
|
||||
// reachable exit, and the launch would fail anyway.
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(vpn_id) = profile.vpn_id.as_deref() {
|
||||
let config = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|storage| storage.load_config(vpn_id).ok());
|
||||
return match config {
|
||||
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
|
||||
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
|
||||
Err(error) => ExitReachability::Unknown {
|
||||
reason: format!("VPN config could not be parsed ({error})"),
|
||||
source: "VPN",
|
||||
},
|
||||
},
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a VPN config that no longer exists".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ExitReachability::None
|
||||
}
|
||||
|
||||
impl CookieBotScheduleInput {
|
||||
/// Stamp the profile facts onto an input built from user-chosen values.
|
||||
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
|
||||
self.sync_enabled = state.sync_enabled;
|
||||
self.encrypted_sync = state.encrypted_sync;
|
||||
self.has_proxy = state.has_proxy;
|
||||
self.proxy_remote_reachable = state.proxy_remote_reachable;
|
||||
self.touch_fingerprint = state.touch_fingerprint;
|
||||
self.sticky_exit = state.sticky_exit;
|
||||
self
|
||||
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
|
||||
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
|
||||
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
|
||||
body.insert("has_proxy".to_string(), state.has_proxy.into());
|
||||
body.insert(
|
||||
"proxy_remote_reachable".to_string(),
|
||||
state.proxy_remote_reachable.into(),
|
||||
);
|
||||
body.insert(
|
||||
"touch_fingerprint".to_string(),
|
||||
state.touch_fingerprint.into(),
|
||||
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
|
||||
.await
|
||||
}
|
||||
|
||||
// --- User-defined templates -------------------------------------------------
|
||||
//
|
||||
// The caller's own saved site lists. Unlike every other route in this file
|
||||
// these are addressed by an id the SERVER minted and the client echoes back,
|
||||
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
|
||||
// colon in a path segment is a spelling the router is free to read differently.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateListEnvelope {
|
||||
#[serde(default)]
|
||||
templates: Vec<CookieBotUserTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateEnvelope {
|
||||
template: CookieBotUserTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateDeleted {
|
||||
#[serde(default)]
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
/// Every site list this user has saved, most recently edited first.
|
||||
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
|
||||
let envelope: UserTemplateListEnvelope = request(
|
||||
reqwest::Method::GET,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.templates)
|
||||
}
|
||||
|
||||
/// Save a new one.
|
||||
pub async fn create_user_template(
|
||||
name: &str,
|
||||
sites: &[String],
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let body = serde_json::json!({ "name": name, "sites": sites });
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::POST,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
Some(body),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Rename one, replace its sites, or both.
|
||||
///
|
||||
/// A PATCH with only the fields that changed, because the two are independent:
|
||||
/// a rename that had to carry the whole site list is a rename that silently
|
||||
/// reverts an edit made to it from another device in the meantime. Sending an
|
||||
/// omitted field as `null` would defeat that, so each is skipped when absent.
|
||||
pub async fn update_user_template(
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
sites: Option<&[String]>,
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(name) = name {
|
||||
body.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(sites) = sites {
|
||||
body.insert("sites".to_string(), serde_json::json!(sites));
|
||||
}
|
||||
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::PATCH,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
Some(serde_json::Value::Object(body)),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Delete one. Enrolments that used it keep the sites they copied, so this is
|
||||
/// never a way to stop a profile being warmed tonight.
|
||||
///
|
||||
/// Safe to repeat: deleting a list that is already gone answers `false` rather
|
||||
/// than 404, which is what makes a retry after a dropped response harmless.
|
||||
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
|
||||
let deleted: UserTemplateDeleted = request(
|
||||
reqwest::Method::DELETE,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(deleted.deleted)
|
||||
}
|
||||
|
||||
// --- Tauri commands ---------------------------------------------------------
|
||||
//
|
||||
// The user-template commands live here rather than in `lib.rs` beside the
|
||||
// schedule ones because they carry no local precondition: nothing about a saved
|
||||
// site list depends on a profile this machine holds, so there is no profile to
|
||||
// look up and no `bot_precondition` to apply. They must still be registered in
|
||||
// `lib.rs`'s `invoke_handler` to be reachable.
|
||||
|
||||
/// Log a refusal and hand the frontend the envelope it translates.
|
||||
///
|
||||
/// The raw HTTP text never reaches the user: an untranslated backend sentence
|
||||
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
|
||||
fn command_error(context: &str, err: CookieBotError) -> String {
|
||||
log::warn!(
|
||||
"Cookie bot {context} failed: {} (HTTP {})",
|
||||
err.code(),
|
||||
err.status()
|
||||
);
|
||||
err.to_error_json()
|
||||
}
|
||||
|
||||
/// Every site list this user has saved.
|
||||
#[tauri::command]
|
||||
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
|
||||
list_user_templates()
|
||||
.await
|
||||
.map_err(|e| command_error("template list", e))
|
||||
}
|
||||
|
||||
/// Save the current site list under a name.
|
||||
#[tauri::command]
|
||||
pub async fn create_cookie_bot_user_template(
|
||||
name: String,
|
||||
sites: Vec<String>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
create_user_template(&name, &sites)
|
||||
.await
|
||||
.map_err(|e| command_error("template create", e))
|
||||
}
|
||||
|
||||
/// Rename a saved list, replace its sites, or both. Omitted fields are left
|
||||
/// exactly as they are.
|
||||
#[tauri::command]
|
||||
pub async fn update_cookie_bot_user_template(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
sites: Option<Vec<String>>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
update_user_template(&id, name.as_deref(), sites.as_deref())
|
||||
.await
|
||||
.map_err(|e| command_error("template update", e))
|
||||
}
|
||||
|
||||
/// Delete a saved list. `false` means there was nothing left to delete.
|
||||
#[tauri::command]
|
||||
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
|
||||
delete_user_template(&id)
|
||||
.await
|
||||
.map_err(|e| command_error("template delete", e))
|
||||
}
|
||||
|
||||
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
|
||||
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
|
||||
let query = period
|
||||
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::types::SyncMode;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
|
||||
fn eligible_profile() -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
@@ -1045,7 +1435,8 @@ mod tests {
|
||||
// that emptiness over the user's real profile.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Disabled;
|
||||
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("a local-only profile must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
|
||||
}
|
||||
|
||||
@@ -1055,7 +1446,8 @@ mod tests {
|
||||
// one code cannot carry two different instructions.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Encrypted;
|
||||
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("encrypted sync must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
|
||||
}
|
||||
|
||||
@@ -1063,7 +1455,8 @@ mod tests {
|
||||
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("linux".to_string());
|
||||
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("linux has no host to lease");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
|
||||
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
|
||||
assert_eq!(
|
||||
@@ -1076,7 +1469,8 @@ mod tests {
|
||||
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = None;
|
||||
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("no OS means no matching host");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
|
||||
}
|
||||
|
||||
@@ -1087,7 +1481,8 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = None;
|
||||
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::None)
|
||||
.expect_err("datacenter egress must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
|
||||
}
|
||||
|
||||
@@ -1096,21 +1491,60 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = Some("vpn-1".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("windows".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_only_this_machine_can_reach_is_refused() {
|
||||
// The gap `has_proxy` alone could never see, and — before the verdict became
|
||||
// an argument — a case no unit test could construct, because resolving it
|
||||
// reached into the global proxy store. The profile is otherwise perfect.
|
||||
let profile = eligible_profile();
|
||||
|
||||
let err = bot_precondition(
|
||||
&profile,
|
||||
&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
)
|
||||
.expect_err("a loopback exit cannot be dialled from a leased host");
|
||||
|
||||
// Its own code: "attach a proxy" is unactionable advice for someone whose
|
||||
// proxy is plainly attached.
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_we_could_not_read_is_refused_too() {
|
||||
// Fails closed. Refusing a working setup costs one support question;
|
||||
// accepting a broken one burns a leased hour and damages an identity.
|
||||
let err = bot_precondition(
|
||||
&eligible_profile(),
|
||||
&ExitReachability::Unknown {
|
||||
reason: "VPN config could not be parsed".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
)
|
||||
.expect_err("an unreadable exit must not be assumed reachable");
|
||||
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
|
||||
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
|
||||
const SERVER_SCHEDULE_VIEW: &str = r#"{
|
||||
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
|
||||
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
|
||||
"run_at_minute":120,"days_mask":127,
|
||||
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
|
||||
"timezone":"Europe/Berlin","template_id":null,
|
||||
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
|
||||
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
|
||||
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
|
||||
@@ -1142,6 +1576,132 @@ mod tests {
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
|
||||
// The mirrored pair is `slots[0]`, so a client that read only the pair
|
||||
// would show "every night at 02:00" for an enrolment that also runs at
|
||||
// 11:30 on weeknights — fewer runs than the user booked, silently.
|
||||
let schedule: CookieBotSchedule =
|
||||
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
|
||||
|
||||
assert_eq!(schedule.slots.len(), 2);
|
||||
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
|
||||
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
|
||||
assert_eq!(schedule.slots[1].run_at_minute, 690);
|
||||
assert_eq!(schedule.slots[1].days_mask, 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
|
||||
// `slots` absent is a deployment that has not rolled forward, not a broken
|
||||
// enrolment. Requiring it would blank the whole Cookie Bot surface against
|
||||
// an older backend rather than show the one time it does know about.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
|
||||
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
|
||||
"max_minutes":10}"#,
|
||||
)
|
||||
.expect("a pre-multi-slot schedule must deserialize");
|
||||
|
||||
assert!(schedule.slots.is_empty());
|
||||
assert!(schedule.template_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_enrolment_reports_its_template_and_no_sites() {
|
||||
// A built-in template's URLs are server-owned. An empty `sites` here is the
|
||||
// contract working, not a schedule with nothing to browse — anything that
|
||||
// reads it as "no sites" would show a healthy enrolment as broken.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
&SERVER_SCHEDULE_VIEW
|
||||
.replace(
|
||||
"\"template_id\":null",
|
||||
"\"template_id\":\"low-intent-purchaser\"",
|
||||
)
|
||||
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
|
||||
)
|
||||
.expect("a templated schedule must deserialize");
|
||||
|
||||
assert_eq!(
|
||||
schedule.template_id.as_deref(),
|
||||
Some("low-intent-purchaser")
|
||||
);
|
||||
assert!(schedule.sites.is_empty());
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
|
||||
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
|
||||
// and REFUSES a null or empty one. Serialising `None` as null would 400
|
||||
// every write from a form with a single time on it.
|
||||
let one_slot = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "Europe/Berlin".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: vec!["https://example.com".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
|
||||
assert!(
|
||||
encoded.get("slots").is_none(),
|
||||
"an absent calendar must be absent on the wire, not null"
|
||||
);
|
||||
assert!(encoded.get("template_id").is_none());
|
||||
|
||||
let many = CookieBotScheduleInput {
|
||||
slots: Some(vec![
|
||||
CookieBotSlot {
|
||||
days_mask: 127,
|
||||
run_at_minute: 120,
|
||||
},
|
||||
CookieBotSlot {
|
||||
days_mask: 31,
|
||||
run_at_minute: 690,
|
||||
},
|
||||
]),
|
||||
..one_slot
|
||||
};
|
||||
let encoded = serde_json::to_value(&many).expect("input must serialize");
|
||||
let slots = encoded["slots"].as_array().expect("slots must be a list");
|
||||
assert_eq!(slots.len(), 2);
|
||||
// Mirrored, because a server that predates multi-slot ignores `slots` and
|
||||
// stores this pair. Dropping it would leave that server with no time at all.
|
||||
assert_eq!(encoded["run_at_minute"], 120);
|
||||
assert_eq!(encoded["days_mask"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_write_names_the_template_and_sends_no_sites() {
|
||||
// The server refuses a body carrying both: a curated persona merged with
|
||||
// the user's own list is neither.
|
||||
let input = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "UTC".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: Vec::new(),
|
||||
template_id: Some("low-intent-purchaser".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&input).expect("input must serialize");
|
||||
assert_eq!(encoded["template_id"], "low-intent-purchaser");
|
||||
assert_eq!(
|
||||
encoded["sites"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"sites must still be sent, and must be empty, beside a template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
|
||||
// The whole point of `blocked_by`: a profile whose proxy was detached in
|
||||
@@ -1419,5 +1979,76 @@ mod tests {
|
||||
assert_eq!(presets.presets[0].id, "balanced");
|
||||
assert_eq!(presets.presets[0].typical_minutes, Some(35));
|
||||
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
|
||||
// An older deployment sends neither of these, and the dialog has to render
|
||||
// against it: no templates simply means the picker offers the user's own
|
||||
// list, and no limits means the mirrored bounds apply.
|
||||
assert!(presets.templates.is_empty());
|
||||
assert!(presets.limits.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
|
||||
// The pool is server-owned for the same reason a preset's browsing model
|
||||
// is. If this type ever gained a `sites` field the curation would be
|
||||
// published, and a published list is one a retailer can filter.
|
||||
let presets: CookieBotPresetList = serde_json::from_str(
|
||||
r#"{"presets":[],"default_preset":"balanced",
|
||||
"templates":[{"id":"low-intent-purchaser","site_count":32,
|
||||
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
|
||||
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
|
||||
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
|
||||
"max_template_name_length":80}}"#,
|
||||
)
|
||||
.expect("the preset list must carry templates and limits");
|
||||
|
||||
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
|
||||
assert_eq!(presets.templates[0].site_count, 32);
|
||||
let limits = presets.limits.expect("limits must decode");
|
||||
assert_eq!(limits.max_slots, Some(14));
|
||||
assert_eq!(limits.max_template_name_length, Some(80));
|
||||
assert_eq!(limits.max_sites, Some(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
|
||||
// The id is what `template_id` takes verbatim. Handing the client a bare
|
||||
// uuid and expecting it to prepend `user:` is how a saved list gets looked
|
||||
// up against the built-in catalogue instead — which answers "no sites" and
|
||||
// silently unschedules the profile.
|
||||
let envelope: UserTemplateListEnvelope = serde_json::from_str(
|
||||
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
|
||||
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
|
||||
)
|
||||
.expect("the user template list must deserialize");
|
||||
|
||||
let template = &envelope.templates[0];
|
||||
assert!(template.id.starts_with("user:"));
|
||||
assert_eq!(template.name, "My shops");
|
||||
assert_eq!(template.sites.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
|
||||
// The route never 404s, so a delete retried after a dropped response has to
|
||||
// read as "nothing left to do" rather than as an error the user must act on.
|
||||
let deleted: UserTemplateDeleted =
|
||||
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
|
||||
.expect("a no-op delete must deserialize");
|
||||
assert!(!deleted.deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
|
||||
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
|
||||
// their profile is not enrolled, and a name collision that a teammate
|
||||
// already warms the profile.
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,9 @@ async fn enforce_direct_exit(
|
||||
profile: &BrowserProfile,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -207,22 +210,28 @@ pub async fn enforce_fingerprint_gate(
|
||||
if upstream.is_none() && !declares_route {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(key) = fingerprint_consistency::exit_cache_key(profile) else {
|
||||
// Declares a route we can no longer resolve at all (e.g. the stored proxy
|
||||
// was deleted). Nothing identifies the exit, so measure the direct one.
|
||||
if declares_route {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that did not resolve; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
};
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Decide *once*, before any consent handling, whether this launch is going
|
||||
// out directly. Both a route that no longer resolves (deleted proxy) and one
|
||||
// that produced no usable upstream (a VPN worker with no local port) end up
|
||||
// connecting directly, and both must mint and redeem consent under the same
|
||||
// identity — splitting that decision across the function meant the first
|
||||
// attempt minted under "direct" while the retry redeemed against the proxy
|
||||
// identity, so "Launch anyway" could never succeed.
|
||||
let key = fingerprint_consistency::exit_cache_key(profile);
|
||||
if key.is_none() || upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
let key = key.expect("checked above");
|
||||
|
||||
// Ack first: a persisted acknowledgement already permits this launch, so a
|
||||
// stale token must not turn it into a hard failure.
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
|
||||
@@ -234,16 +243,6 @@ pub async fn enforce_fingerprint_gate(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The route is known but produced no usable upstream (e.g. a VPN worker that
|
||||
// came up without a local port). The browser still launches, direct.
|
||||
if upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} has a route that yielded no upstream; measuring the direct exit",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
|
||||
let result = if matches!(gate, FingerprintGate::Advisory) {
|
||||
// Automation: answer from a warm cache or say nothing. Probing here would
|
||||
// add seconds to every profile in a batch run.
|
||||
@@ -390,9 +389,13 @@ pub async fn ack_launch_gate(
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
if ack_fingerprint {
|
||||
if let Some(key) = fingerprint_consistency::exit_cache_key(&profile) {
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &key.identity);
|
||||
}
|
||||
// Must match the identity the block was issued against. A profile whose
|
||||
// route did not resolve is gated on the direct exit and has no cache key,
|
||||
// so falling back here is what makes "don't block again" stick for it.
|
||||
let identity = fingerprint_consistency::exit_cache_key(&profile)
|
||||
.map(|key| key.identity)
|
||||
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
|
||||
}
|
||||
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
|
||||
Ok(())
|
||||
@@ -506,21 +509,32 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
|
||||
// No exit identity means nothing to compare against, so the launch must
|
||||
// proceed rather than block on an unmeasurable profile.
|
||||
// A profile that declares no route has no upstream either — that pairing is
|
||||
// the only one the launcher can actually produce. It must return without
|
||||
// measuring anything, so this stays a pure unit test with no network.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let upstream = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, Some(&upstream), &FingerprintGate::Enforce)
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
|
||||
// Regression: a route that yields no usable upstream is gated on the direct
|
||||
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
|
||||
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
|
||||
// fail forever and the profile could never be started.
|
||||
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
profile.vpn_id = Some("vpn-with-no-port".into());
|
||||
|
||||
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
|
||||
// No upstream: the launcher could not bring the route up.
|
||||
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"consent minted for the direct exit must be redeemable, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-3
@@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
|
||||
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
|
||||
}
|
||||
|
||||
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
|
||||
/// can say so instead of implying a typo.
|
||||
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
|
||||
serde_json::json!({
|
||||
"code": "VLESS_CONFIG_INVALID",
|
||||
"params": { "reason": error.reason_code(), "detail": error.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn e2e_automation_enabled() -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
{
|
||||
@@ -75,6 +85,7 @@ mod proxy_manager;
|
||||
pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod remote_exit;
|
||||
mod remote_handoff;
|
||||
mod remote_session;
|
||||
mod settings_manager;
|
||||
@@ -84,6 +95,7 @@ mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
mod window_decorations;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
pub mod cloud_auth;
|
||||
mod cloud_errors;
|
||||
@@ -317,6 +329,16 @@ async fn create_stored_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a VLESS URI without touching the network, so the proxy form can
|
||||
/// tell the user their setup is unsupported while they are still editing it
|
||||
/// rather than only after they try to save or launch.
|
||||
#[tauri::command]
|
||||
fn validate_vless_uri(uri: String) -> Result<(), String> {
|
||||
crate::xray::parse_vless_uri(uri.trim())
|
||||
.map(|_| ())
|
||||
.map_err(|error| vless_config_error(&error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
|
||||
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
|
||||
@@ -1447,7 +1469,7 @@ async fn save_cookie_bot_schedule(
|
||||
// Refused here rather than at 02:00: a profile that can never be warmed
|
||||
// should never reach a schedule row, an hour of quota or a leased host.
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
// The frontend sends the user's choices; the profile facts the server refuses
|
||||
// a run on are stamped here, from the profile itself, so a caller cannot
|
||||
// assert them.
|
||||
@@ -1507,7 +1529,8 @@ async fn run_cookie_bot_now(
|
||||
profile_id: String,
|
||||
max_minutes: Option<u32>,
|
||||
) -> Result<cookie_bot::CookieBotRunStarted, String> {
|
||||
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
cookie_bot::run_now(&profile_id, max_minutes)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run start", e))
|
||||
@@ -1769,7 +1792,12 @@ pub fn run_with_builder(
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
|
||||
// Whether the window is decorated is decided per-session by
|
||||
// `window_decorations::use_client_side_decorations()`, not by what
|
||||
// a previous run saved. Restoring it would put a real titlebar back
|
||||
// on top of the one the app draws — or strip both.
|
||||
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
@@ -1809,9 +1837,21 @@ pub fn run_with_builder(
|
||||
None => win_builder,
|
||||
};
|
||||
|
||||
// The app draws its own titlebar. macOS keeps the native one and makes
|
||||
// it transparent (below); Windows and Linux drop decorations entirely and
|
||||
// render their own controls.
|
||||
#[cfg(target_os = "windows")]
|
||||
let win_builder = win_builder.decorations(false);
|
||||
|
||||
// Linux opts out on the one configuration where dropping decorations can
|
||||
// make things worse rather than better — see `use_client_side_decorations`.
|
||||
#[cfg(target_os = "linux")]
|
||||
let win_builder = if window_decorations::use_client_side_decorations() {
|
||||
win_builder.decorations(false)
|
||||
} else {
|
||||
win_builder
|
||||
};
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
@@ -1844,6 +1884,44 @@ pub fn run_with_builder(
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the desktop's titlebar button layout to the frontend. Runs
|
||||
// here because `setup` is the GTK main thread, which `gtk::Settings`
|
||||
// requires.
|
||||
//
|
||||
// The decorated state is logged alongside it: "my window has no titlebar"
|
||||
// and "my window has two titlebars" are both reports that hinge on this
|
||||
// one boolean, and it is otherwise invisible after the fact.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
log::info!(
|
||||
"Linux window decorations: server-side = {:?}",
|
||||
window.is_decorated()
|
||||
);
|
||||
|
||||
// tao makes the window visible before it clears the decorations, so it
|
||||
// is realized while still framed and the frame extents come out of the
|
||||
// size we asked for (a requested 880x500 arrives noticeably smaller).
|
||||
//
|
||||
// Only correct that on a first run. Once window-state has geometry
|
||||
// saved, that geometry is the user's and has already been restored —
|
||||
// re-applying the default here would move and resize their window on
|
||||
// every launch, and the plugin would then persist the reset.
|
||||
let has_saved_geometry = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map(|dir| dir.join(".window-state.json").exists())
|
||||
.unwrap_or(false);
|
||||
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
|
||||
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
|
||||
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
|
||||
}
|
||||
if let Err(e) = window.center() {
|
||||
log::warn!("Failed to re-center the window after dropping decorations: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
window_decorations::init(app.handle());
|
||||
|
||||
// Set transparent titlebar for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -2679,6 +2757,8 @@ pub fn run_with_builder(
|
||||
fingerprint_consistency::match_profile_fingerprint_to_exit,
|
||||
launch_gate::get_profile_pre_launch_checks,
|
||||
launch_gate::ack_launch_gate,
|
||||
window_decorations::get_window_decoration_layout,
|
||||
validate_vless_uri,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
@@ -2775,6 +2855,14 @@ pub fn run_with_builder(
|
||||
get_cookie_bot_presets,
|
||||
get_remote_hours_quota,
|
||||
get_cookie_bot_usage,
|
||||
// Defined in `cookie_bot.rs` rather than here because they carry no local
|
||||
// precondition — there is no profile to look up and no `bot_precondition`
|
||||
// to apply. Unregistered they are unreachable, and the saved-list tab
|
||||
// fails at runtime with "command not found" rather than at build time.
|
||||
cookie_bot::get_cookie_bot_user_templates,
|
||||
cookie_bot::create_cookie_bot_user_template,
|
||||
cookie_bot::update_cookie_bot_user_template,
|
||||
cookie_bot::delete_cookie_bot_user_template,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
|
||||
+26
-13
@@ -2341,11 +2341,10 @@ impl McpServer {
|
||||
"check_cookie_bot_conflicts" => Self::handle_check_cookie_bot_conflicts(arguments).await,
|
||||
"list_cookie_bot_runs" => Self::handle_list_cookie_bot_runs(arguments).await,
|
||||
"run_cookie_bot_now" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
// The Cookie Bot, NOT browser automation. Solo pays for the bot and has
|
||||
// no automation; gating this on automation refused a Solo customer the
|
||||
// feature their plan is sold on while their scheduled runs kept firing.
|
||||
Self::require_capability("Cookie Bot", CLOUD_AUTH.can_use_cookie_bot().await).await?;
|
||||
Self::handle_run_cookie_bot_now(arguments).await
|
||||
}
|
||||
// No capability gate on the cancel. A lapsed plan must never be the
|
||||
@@ -5730,10 +5729,11 @@ impl McpServer {
|
||||
message: format!("Profile not found: {profile_id}"),
|
||||
})?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile).map_err(|message| McpError {
|
||||
code: -32000,
|
||||
message,
|
||||
})?;
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
.map_err(|message| McpError {
|
||||
code: -32000,
|
||||
message,
|
||||
})?;
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -6177,19 +6177,28 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(crate::cookie_bot::bot_precondition(&eligible()).is_ok());
|
||||
assert!(crate::cookie_bot::bot_precondition(
|
||||
&eligible(),
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let mut local_only = eligible();
|
||||
local_only.sync_mode = SyncMode::Disabled;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&local_only,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a profile with no cloud copy has nothing for a host to open"
|
||||
);
|
||||
|
||||
let mut linux = eligible();
|
||||
linux.host_os = Some("linux".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&linux).is_err(),
|
||||
crate::cookie_bot::bot_precondition(&linux, &crate::remote_exit::ExitReachability::Remote)
|
||||
.is_err(),
|
||||
"the fleet cannot lease a linux host"
|
||||
);
|
||||
|
||||
@@ -6197,7 +6206,11 @@ mod tests {
|
||||
datacenter_egress.proxy_id = None;
|
||||
datacenter_egress.vpn_id = None;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&datacenter_egress,
|
||||
&crate::remote_exit::ExitReachability::None
|
||||
)
|
||||
.is_err(),
|
||||
"hours of traffic from a hosting ASN damages the identity being warmed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -464,10 +464,10 @@ impl ProxyManager {
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.is_empty())
|
||||
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
|
||||
let parsed = crate::xray::parse_vless_uri(uri)
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
let parsed =
|
||||
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
|
||||
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| crate::vless_config_error(&error))?;
|
||||
|
||||
proxy_settings.proxy_type = "vless".to_string();
|
||||
proxy_settings.host = parsed.config.address;
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Whether a profile's exit node can be reached from somewhere that is not this
|
||||
//! machine.
|
||||
//!
|
||||
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
|
||||
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
|
||||
//! VPN config) is pulled from the user's sync namespace. Nothing in that
|
||||
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
|
||||
//! on the fleet host meaning *the fleet host's own loopback*.
|
||||
//!
|
||||
//! That is the whole bug this module exists to prevent. The server already
|
||||
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
|
||||
//! from the fleet's datacenter address damages an identity rather than building
|
||||
//! it — but it was asking whether an exit was *configured*, never whether it was
|
||||
//! *reachable*. A local proxy satisfied the first question and failed the
|
||||
//! second, so the run was accepted, dispatched, and burned a leased host either
|
||||
//! erroring out or (worse) egressing direct from the datacenter: exactly the
|
||||
//! outcome `proxy_required` exists to stop, reached by the one route it did not
|
||||
//! check.
|
||||
//!
|
||||
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
|
||||
//! locally-run SOCKS client and Donut's own VLESS support all present to the
|
||||
//! browser as `127.0.0.1:<port>`.
|
||||
//!
|
||||
//! This module is the single answer, shared by every caller, and it FAILS
|
||||
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
|
||||
//! working setup costs the user one support question; accepting a broken one
|
||||
//! costs a burned hour and a damaged profile identity.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Whether a leased fleet host could dial this profile's exit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExitReachability {
|
||||
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
|
||||
None,
|
||||
/// An address a host elsewhere on the internet can reach.
|
||||
Remote,
|
||||
/// An address that only means anything on this machine or this LAN.
|
||||
LocalOnly {
|
||||
/// The offending host, for a message the user can act on.
|
||||
host: String,
|
||||
/// Which part of the config it came from: "proxy" or "VPN".
|
||||
source: &'static str,
|
||||
},
|
||||
/// Configured, but this code could not determine the host.
|
||||
///
|
||||
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
|
||||
/// fail-closed note in the module docs.
|
||||
Unknown {
|
||||
reason: String,
|
||||
source: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExitReachability {
|
||||
/// Whether remote execution may proceed.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, ExitReachability::Remote)
|
||||
}
|
||||
|
||||
/// A one-line reason for a refusal, or None when there is nothing to refuse.
|
||||
pub fn refusal_detail(&self) -> Option<String> {
|
||||
match self {
|
||||
ExitReachability::Remote | ExitReachability::None => None,
|
||||
ExitReachability::LocalOnly { host, source } => Some(format!(
|
||||
"The {source} for this profile points at {host}, which only exists on this computer. \
|
||||
Remote runs happen on our hosts and cannot reach it."
|
||||
)),
|
||||
ExitReachability::Unknown { reason, source } => Some(format!(
|
||||
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
|
||||
remote host could use it."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a hostname or IP literal is reachable from another machine.
|
||||
///
|
||||
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
|
||||
/// range that is scoped to a machine or a private network. Hostnames that are
|
||||
/// not IP literals are accepted unless they use a name suffix that is
|
||||
/// definitionally local — a public DNS name cannot be validated here without a
|
||||
/// lookup, and doing a lookup would make this impure and slow on a hot path.
|
||||
pub fn host_is_remote_reachable(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return ip_is_remote_reachable(ip);
|
||||
}
|
||||
|
||||
let lower = host.to_ascii_lowercase();
|
||||
|
||||
// `localhost` and anything under it resolve to loopback everywhere.
|
||||
if lower == "localhost" || lower.ends_with(".localhost") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
|
||||
// 8375, and the names router vendors hand out on a LAN). A fleet host
|
||||
// resolving one of these gets its own network's answer, not the user's.
|
||||
const LOCAL_SUFFIXES: [&str; 7] = [
|
||||
".local",
|
||||
".localdomain",
|
||||
".internal",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".lan",
|
||||
".intranet",
|
||||
];
|
||||
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare single-label name ("my-proxy", "router") is only resolvable through
|
||||
// a local search domain, so it is no more use to a fleet host than `.local`.
|
||||
if !lower.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether an IP literal is routable from another machine.
|
||||
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
|
||||
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
|
||||
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
|
||||
// the remaining ranges are spelled out rather than gated behind a nightly
|
||||
// feature.
|
||||
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
|
||||
return false;
|
||||
}
|
||||
let [a, b, ..] = ip.octets();
|
||||
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
|
||||
// carrier's network and nowhere else.
|
||||
if a == 100 && (64..128).contains(&b) {
|
||||
return false;
|
||||
}
|
||||
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
|
||||
if a == 0 || a >= 240 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
|
||||
// it as one, or `::ffff:127.0.0.1` walks straight through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
if let Some(v4) = ip.to_ipv4() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 unique-local, fe80::/10 link-local.
|
||||
if (segments[0] & 0xfe00) == 0xfc00 {
|
||||
return false;
|
||||
}
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
|
||||
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
|
||||
/// `:port` that came along from a URI.
|
||||
fn normalize_host(raw: &str) -> String {
|
||||
let mut host = raw.trim();
|
||||
if host.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// `user:pass@host` — take what follows the LAST '@', since a password may
|
||||
// itself contain one.
|
||||
if let Some(at) = host.rfind('@') {
|
||||
host = &host[at + 1..];
|
||||
}
|
||||
|
||||
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
|
||||
if let Some(stripped) = host.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
return stripped[..end].trim().to_string();
|
||||
}
|
||||
return stripped.trim().to_string();
|
||||
}
|
||||
|
||||
// `host:port`, but only when there is exactly one colon — more than one means
|
||||
// a bare IPv6 literal, whose colons are part of the address.
|
||||
if host.matches(':').count() == 1 {
|
||||
if let Some((left, _port)) = host.split_once(':') {
|
||||
host = left;
|
||||
}
|
||||
}
|
||||
|
||||
host.trim().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The host a VLESS URI actually dials.
|
||||
///
|
||||
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
|
||||
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
|
||||
/// worker and points the browser at it — but the address that decides whether
|
||||
/// anyone else could use this config is the SERVER inside the URI. The local
|
||||
/// port is an implementation detail of this machine; the URI is the exit.
|
||||
pub fn vless_uri_host(uri: &str) -> Option<String> {
|
||||
let rest = uri.trim().strip_prefix("vless://")?;
|
||||
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
|
||||
// authority — either may contain '@' or ':'.
|
||||
let rest = rest.split('#').next()?;
|
||||
let rest = rest.split('?').next()?;
|
||||
// `uuid@host:port/...`
|
||||
let authority = rest.split('/').next()?;
|
||||
let host_port = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority);
|
||||
let host = normalize_host(host_port);
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host)
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit host a stored proxy represents, as a remote host would have to dial
|
||||
/// it.
|
||||
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
|
||||
if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
let uri = settings
|
||||
.vless_uri
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.trim().is_empty())
|
||||
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
|
||||
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
|
||||
}
|
||||
|
||||
let host = normalize_host(&settings.host);
|
||||
if host.is_empty() {
|
||||
return Err("proxy has no host".to_string());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Classify a stored proxy.
|
||||
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
|
||||
match proxy_exit_host(settings) {
|
||||
Err(reason) => ExitReachability::Unknown {
|
||||
reason,
|
||||
source: "proxy",
|
||||
},
|
||||
Ok(host) => {
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "proxy",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a WireGuard peer endpoint (`host:port`).
|
||||
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
|
||||
let host = normalize_host(peer_endpoint);
|
||||
if host.is_empty() {
|
||||
return ExitReachability::Unknown {
|
||||
reason: "VPN config has no peer endpoint".to_string(),
|
||||
source: "VPN",
|
||||
};
|
||||
}
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "VPN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::ProxySettings;
|
||||
|
||||
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
|
||||
ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_in_every_spelling_is_local() {
|
||||
// The literal case the bug was reported for, plus the spellings that reach
|
||||
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
|
||||
for host in [
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"foo.localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"::ffff:127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_and_carrier_ranges_are_local() {
|
||||
for host in [
|
||||
"10.0.0.1",
|
||||
"192.168.1.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.254",
|
||||
"169.254.1.1", // link-local / APIPA
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.1",
|
||||
"fd00::1", // unique-local
|
||||
"fe80::1", // link-local
|
||||
"240.0.0.1",
|
||||
"0.1.2.3",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addresses_and_names_are_reachable() {
|
||||
for host in [
|
||||
"1.1.1.1",
|
||||
"8.8.8.8",
|
||||
"172.15.0.1", // just outside 172.16/12
|
||||
"172.32.0.1",
|
||||
"100.63.255.255", // just outside 100.64/10
|
||||
"100.128.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"proxy.example.com",
|
||||
"gate.smartproxy.net.",
|
||||
"residential.example.co.uk",
|
||||
] {
|
||||
assert!(
|
||||
host_is_remote_reachable(host),
|
||||
"{host} should be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_only_names_are_local() {
|
||||
// A fleet host resolving these gets ITS network's answer, not the user's —
|
||||
// which is worse than failing, because it may well succeed against
|
||||
// something unrelated.
|
||||
for host in [
|
||||
"my-proxy", // single label: needs a search domain
|
||||
"router.local",
|
||||
"nas.home.arpa",
|
||||
"proxy.lan",
|
||||
"box.internal",
|
||||
"server.localdomain",
|
||||
"gateway.intranet",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_and_credentials_are_stripped_before_classifying() {
|
||||
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("[::1]:1080"));
|
||||
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
|
||||
// THE asymmetry. Donut points the browser at a local xray worker, so the
|
||||
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
|
||||
// config names a real server, and that is what a fleet host would dial.
|
||||
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri =
|
||||
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
|
||||
|
||||
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_uri_pointing_at_loopback_is_still_local() {
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
|
||||
|
||||
assert_eq!(
|
||||
classify_proxy(&settings),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vless_host_parsing_survives_query_and_fragment() {
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
|
||||
Some("example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
|
||||
Some("2606:4700::1111".to_string())
|
||||
);
|
||||
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_config_fails_closed() {
|
||||
// Unknown must never be treated as usable: the point of the check is that
|
||||
// we could not confirm reachability, and guessing "yes" reintroduces the
|
||||
// exact failure it prevents.
|
||||
let mut settings = proxy("vless", "");
|
||||
settings.vless_uri = None;
|
||||
let verdict = classify_proxy(&settings);
|
||||
|
||||
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
|
||||
assert!(!verdict.is_remote());
|
||||
assert!(verdict.refusal_detail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_proxies_are_classified_by_host() {
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("socks5", "gate.example.com")),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("http", "192.168.0.10")),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "192.168.0.10".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_endpoints_are_classified_by_their_peer() {
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("vpn.example.com:51820"),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("10.0.0.1:51820"),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "10.0.0.1".to_string(),
|
||||
source: "VPN",
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_wireguard_endpoint(" "),
|
||||
ExitReachability::Unknown { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_remote_permits_a_run() {
|
||||
assert!(ExitReachability::Remote.is_remote());
|
||||
assert!(!ExitReachability::None.is_remote());
|
||||
assert!(!ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".into(),
|
||||
source: "proxy"
|
||||
}
|
||||
.is_remote());
|
||||
// `None` has no detail: the caller's existing "no exit at all" refusal is
|
||||
// the better message, and two refusals for one condition read as a bug.
|
||||
assert!(ExitReachability::None.refusal_detail().is_none());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
use crate::cloud_errors::{self, FailureCodes};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -115,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
|
||||
format!("run-remote:{profile_id}:{attempt}")
|
||||
}
|
||||
|
||||
/// Whether this profile's exit rules out running it on a leased host.
|
||||
///
|
||||
/// A session runs on a fleet host that pulls the profile — and its proxy record
|
||||
/// — out of the user's sync namespace, rewriting no addresses along the way. A
|
||||
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
|
||||
/// loopback: the browser either cannot connect and the leased hour is burned on
|
||||
/// a session that never worked, or it falls through and the user's identity
|
||||
/// egresses from our datacenter. The Cookie Bot has refused this since
|
||||
/// `remote_exit` existed; interactive sessions take the same profile onto the
|
||||
/// same hosts and did not, so the same mistake cost a leased hour here.
|
||||
///
|
||||
/// A profile with NO exit at all is deliberately allowed through. The Cookie
|
||||
/// Bot refuses that separately because a night of unattended browsing from a
|
||||
/// hosting ASN damages an identity, but an interactive session is a person at a
|
||||
/// keyboard who chose to open this profile and can see where it comes out —
|
||||
/// and no rule has ever required an exit here. Refusing it would be a new
|
||||
/// product restriction wearing this bug's error code.
|
||||
///
|
||||
/// Split out from the launch because that is the only testable seam:
|
||||
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
|
||||
/// itself needs a fleet.
|
||||
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
|
||||
match verdict {
|
||||
// An address anyone can dial, so the leased host can dial it too.
|
||||
ExitReachability::Remote => None,
|
||||
// See the second paragraph above: allowed on purpose, not overlooked.
|
||||
ExitReachability::None => None,
|
||||
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
|
||||
// not read the config and which fails closed by design, because "we could
|
||||
// not confirm it" guessed as "yes" is the failure this whole check exists
|
||||
// to stop.
|
||||
unusable => {
|
||||
// The prose names the offending host, which belongs in the log where
|
||||
// support can read it. The toast gets the code so it stays translated.
|
||||
if let Some(detail) = unusable.refusal_detail() {
|
||||
log::warn!("Refusing an interactive remote session: {detail}");
|
||||
}
|
||||
// `Other` rather than a typed variant: the other three are each pinned to
|
||||
// a status and a meaning — "the fleet is busy", "already open somewhere",
|
||||
// "not on your plan" — and this refusal is none of them. The code in the
|
||||
// body is what every surface renders.
|
||||
Some(RemoteSessionError::Other(
|
||||
serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to start a remote session for this profile.
|
||||
///
|
||||
/// Goes through `api_call_with_retry` so an expired access token is refreshed
|
||||
@@ -133,6 +182,14 @@ pub async fn start_remote_session(
|
||||
.to_string();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
// Checked here, before the request: the backend is told which profile to
|
||||
// start but never sees the proxy record, so it cannot derive this — and by
|
||||
// the time it could, an hour is already leased and billed. Resolving a proxy
|
||||
// id to an address is only possible on the machine that stores it.
|
||||
if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) {
|
||||
return Err(refusal);
|
||||
}
|
||||
|
||||
// One key for this user action: a retry inside api_call_with_retry must
|
||||
// de-duplicate rather than open a second browser on the same profile.
|
||||
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
|
||||
@@ -1180,6 +1237,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
|
||||
// The profile and its proxy record are copied onto the fleet unrewritten,
|
||||
// so this loopback address would mean the FLEET's loopback. Accepting the
|
||||
// launch bills an hour for a session that cannot reach the user's exit.
|
||||
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("a loopback proxy is unusable from a leased host");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_that_could_not_be_read_is_refused_too() {
|
||||
// `Unknown` is "we could not confirm this works from elsewhere". Treating
|
||||
// that as a yes reintroduces exactly the burned hour above, so it fails
|
||||
// closed here as it does everywhere else `remote_exit` is consulted.
|
||||
let refusal = local_exit_refusal(&ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("an unreadable exit is not evidence of a reachable one");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() {
|
||||
assert!(local_exit_refusal(&ExitReachability::Remote).is_none());
|
||||
// Deliberate, and the reason this gate is not simply `!is_remote()`: a
|
||||
// proxyless interactive session has always been permitted, and refusing it
|
||||
// with a code that says "your proxy is local" would be both a new product
|
||||
// rule and a sentence that does not describe the profile.
|
||||
assert!(local_exit_refusal(&ExitReachability::None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
|
||||
// Once infra sends an envelope, its code must win over the status default
|
||||
|
||||
@@ -22,6 +22,9 @@ const MAX_EXTENSION_DIRS: usize = 300;
|
||||
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
|
||||
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
|
||||
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
|
||||
/// Chromium preference files are larger than a manifest but still bounded; a
|
||||
/// pathological one must not be parsed while the user waits for a browser.
|
||||
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Chromium profile directories to search inside a user-data dir.
|
||||
///
|
||||
@@ -69,6 +72,19 @@ fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Val
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
|
||||
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
|
||||
/// being joined into a filesystem path, so it is refused rather than sanitized
|
||||
/// — this runs on the launch path against extensions the user may have
|
||||
/// sideloaded.
|
||||
fn is_safe_locale_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 32
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn resolve_dir_i18n(
|
||||
version_dir: &Path,
|
||||
manifest: &serde_json::Value,
|
||||
@@ -76,6 +92,10 @@ fn resolve_dir_i18n(
|
||||
) -> Option<String> {
|
||||
let key = message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
if !is_safe_locale_name(default_locale) {
|
||||
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
|
||||
return None;
|
||||
}
|
||||
let messages = read_json_file(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
@@ -105,8 +125,9 @@ struct PreferenceExtensions {
|
||||
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
|
||||
let mut out = PreferenceExtensions::default();
|
||||
for file in ["Secure Preferences", "Preferences"] {
|
||||
// Preference files are legitimately large, so they skip the manifest cap.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), None) else {
|
||||
// Larger than a manifest, but still capped: this is parsed while the user
|
||||
// waits for a browser to start.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
@@ -150,6 +171,9 @@ pub(super) fn scan_browser_extensions(
|
||||
// Read preferences first: unpacked extensions live outside Extensions/, so
|
||||
// a profile that has only sideloaded ones has no Extensions/ dir at all and
|
||||
// must not be skipped before they are considered.
|
||||
if started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
let prefs = preference_extensions(&profile_dir);
|
||||
let disabled = &prefs.disabled;
|
||||
|
||||
@@ -566,6 +590,41 @@ mod tests {
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversing_default_locale_is_refused() {
|
||||
// `default_locale` comes from a manifest we did not write. Joined naively
|
||||
// it reads any file the user can read, on the launch path.
|
||||
assert!(!is_safe_locale_name("../../../../etc"));
|
||||
assert!(!is_safe_locale_name("..\\..\\windows"));
|
||||
assert!(!is_safe_locale_name("/etc/passwd"));
|
||||
assert!(!is_safe_locale_name(""));
|
||||
assert!(is_safe_locale_name("en"));
|
||||
assert!(is_safe_locale_name("en_GB"));
|
||||
assert!(is_safe_locale_name("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
// Still detected (the proxy permission is what matters), but the name
|
||||
// falls back rather than the traversal being followed.
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].name, CRX_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tolerates_a_missing_user_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -142,14 +142,13 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
|
||||
"partial"
|
||||
};
|
||||
|
||||
// The two sources are disjoint by construction, but an imported profile can
|
||||
// carry its own copy of an extension Donut also manages. Collapse only on an
|
||||
// exact name+version match, and never on an unresolved `__MSG_` placeholder —
|
||||
// those are not identities and would fold unrelated extensions into one row.
|
||||
// Collapse only exact duplicates of the same extension. `key` is the real
|
||||
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
|
||||
// distinct extensions sharing a display name would silently fold into one —
|
||||
// dropping a `confirmed` detection would then flip `has_confirmed()` and stop
|
||||
// the gate treating its own exit measurement as unreliable.
|
||||
let mut seen = HashSet::new();
|
||||
extensions.retain(|e| {
|
||||
message_placeholder_key(&e.name).is_some() || seen.insert((e.name.clone(), e.version.clone()))
|
||||
});
|
||||
extensions.retain(|e| seen.insert(e.key.clone()));
|
||||
|
||||
ExtensionScan {
|
||||
extensions,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Client-side window decorations on Linux.
|
||||
//!
|
||||
//! The app draws its own titlebar (as it already does on macOS and Windows), so
|
||||
//! the window is built without server-side decorations. That means the app also
|
||||
//! owns the window *controls*, and their side and order are a desktop-wide user
|
||||
//! preference that differs between environments — GNOME defaults to
|
||||
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
|
||||
//! to the left expects every app to follow.
|
||||
//!
|
||||
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
|
||||
//! publishes that preference to GTK applications: GNOME mirrors
|
||||
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
|
||||
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
|
||||
//! Reading this property therefore gets both environments right without any
|
||||
//! desktop-specific branching.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Whether this window draws its own titlebar, and how.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowDecorations {
|
||||
/// True when the app owns the titlebar and must draw controls and resize
|
||||
/// edges. False means the platform still draws a real titlebar and the
|
||||
/// frontend must render nothing.
|
||||
pub client_side: bool,
|
||||
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
|
||||
/// meaningful when `client_side` is true.
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether to drop server-side decorations on this Linux session.
|
||||
///
|
||||
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
|
||||
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
|
||||
///
|
||||
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
|
||||
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
|
||||
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
|
||||
/// therefore *announces server-side decorations* to the compositor. mutter
|
||||
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
|
||||
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
|
||||
/// directly above the one the app draws — two titlebars, worse than the
|
||||
/// feature is good. Whether it actually does depends on the decoration mode
|
||||
/// KWin advertises, which could not be established from documentation and
|
||||
/// cannot be tested from here, so this stays off until somebody can run it.
|
||||
///
|
||||
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
|
||||
/// which KWin has honored for as long as it has existed.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn use_client_side_decorations() -> bool {
|
||||
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
|
||||
let forced = matches!(value.trim(), "1" | "true" | "yes");
|
||||
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
|
||||
return forced;
|
||||
}
|
||||
|
||||
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
|
||||
|
||||
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
|
||||
// takes the FIRST entry it can open. Testing for a substring would read
|
||||
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
|
||||
// path this guard exists to withhold.
|
||||
let backend = env("GDK_BACKEND");
|
||||
let preferred = backend
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty());
|
||||
let on_wayland = match preferred {
|
||||
Some("x11") => false,
|
||||
Some("wayland") => true,
|
||||
// Unset or something exotic: fall back to what the session advertises.
|
||||
_ => {
|
||||
!std::env::var("WAYLAND_DISPLAY")
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& env("XDG_SESSION_TYPE") != "x11"
|
||||
}
|
||||
};
|
||||
|
||||
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|
||||
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|
||||
|| env("DESKTOP_SESSION").contains("plasma")
|
||||
|| !std::env::var("KDE_FULL_SESSION")
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
|
||||
if on_kde && on_wayland {
|
||||
log::info!(
|
||||
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
|
||||
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod imp {
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
fn store(layout: Option<String>) {
|
||||
if let Ok(mut slot) = LAYOUT.lock() {
|
||||
*slot = layout;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached() -> Option<String> {
|
||||
LAYOUT.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// Read the layout and subscribe to changes.
|
||||
///
|
||||
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
|
||||
/// elsewhere, and the notify subscription has to be attached from the thread
|
||||
/// owning the GTK main context. The app's `setup` hook already runs there.
|
||||
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
use gtk::prelude::*;
|
||||
use tauri::Emitter;
|
||||
|
||||
let Some(settings) = gtk::Settings::default() else {
|
||||
log::warn!("No GTK settings available; using the default decoration layout");
|
||||
return;
|
||||
};
|
||||
|
||||
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
|
||||
log::info!(
|
||||
"Window decoration layout: {}",
|
||||
cached().as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
|
||||
// The user can rearrange titlebar buttons while the app is running, and
|
||||
// every other application follows immediately. Note this never fires where
|
||||
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
|
||||
// xsettings daemon) — there it is simply static until restart.
|
||||
let handle = app.clone();
|
||||
settings.connect_gtk_decoration_layout_notify(move |settings| {
|
||||
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
|
||||
log::info!(
|
||||
"Window decoration layout changed to: {}",
|
||||
layout.as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
store(layout.clone());
|
||||
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
|
||||
log::warn!("Failed to emit window decoration layout change: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod imp {
|
||||
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
|
||||
}
|
||||
|
||||
pub use imp::init;
|
||||
|
||||
/// How this window is decorated, and the desktop's button layout when the app
|
||||
/// owns the titlebar.
|
||||
#[tauri::command]
|
||||
pub fn get_window_decoration_layout() -> WindowDecorations {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let client_side = use_client_side_decorations();
|
||||
WindowDecorations {
|
||||
client_side,
|
||||
layout: if client_side { imp::cached() } else { None },
|
||||
}
|
||||
}
|
||||
// Every other platform keeps a real titlebar: macOS makes the native one
|
||||
// transparent, Windows draws its own controls on a fixed layout.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
WindowDecorations {
|
||||
client_side: false,
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,42 @@ pub enum XrayError {
|
||||
#[error("failed to serialize Xray client configuration")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl XrayError {
|
||||
/// A stable, translatable identifier for *why* a URI was rejected.
|
||||
///
|
||||
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
|
||||
/// rejections are "your setup is a kind we do not support", not "you made a
|
||||
/// typo". The frontend turns these into a sentence naming the unsupported
|
||||
/// part; without them every rejection reads as a malformed URI and a user
|
||||
/// with a working WebSocket or plain-TLS server has no idea why it failed.
|
||||
pub fn reason_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UnsupportedScheme => "scheme",
|
||||
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
"type" => "transport",
|
||||
"encryption" => "encryption",
|
||||
"headerType" => "headerType",
|
||||
"fp" => "fingerprint",
|
||||
// A malformed sni/public key is the same user-facing problem as a
|
||||
// missing one, so it earns the same specific help rather than the
|
||||
// generic "invalid URI".
|
||||
"sni" | "server_name" => "sni",
|
||||
"pbk" | "public_key" => "publicKey",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::MissingField(field) => match *field {
|
||||
"sni" => "sni",
|
||||
"pbk" => "publicKey",
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::UnsupportedParameter(_) => "parameter",
|
||||
Self::DuplicateParameter(_) => "malformed",
|
||||
Self::InvalidUri | Self::Serialization => "malformed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
};
|
||||
let port = url.port().ok_or(XrayError::MissingField("port"))?;
|
||||
|
||||
let parameters = parse_parameters(&url)?;
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
let (parameters, unsupported) = parse_parameters(&url)?;
|
||||
|
||||
// Transport first: it is the most common reason a real-world VLESS server is
|
||||
// unusable here, and it explains the stray parameters that come with it.
|
||||
match parameters.get("type").map(String::as_str) {
|
||||
None | Some("tcp" | "raw") => {}
|
||||
Some(_) => {
|
||||
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
});
|
||||
}
|
||||
}
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
optional_value(¶meters, "headerType", "none")?;
|
||||
|
||||
// Only once the shape is known-good does an unrecognized parameter become
|
||||
// the most useful thing to report.
|
||||
if let Some(name) = unsupported.into_iter().next() {
|
||||
return Err(XrayError::UnsupportedParameter(name));
|
||||
}
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "pbk")?.to_string();
|
||||
let short_id = parameters.get("sid").cloned().unwrap_or_default();
|
||||
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
|
||||
query.append_pair("type", "tcp");
|
||||
query.append_pair("headerType", "none");
|
||||
}
|
||||
url.set_fragment(name);
|
||||
// The parser percent-DECODES the fragment, so the exporter must encode it or
|
||||
// a name containing `%` (or `#`) comes back different every time the URI is
|
||||
// canonicalized — the name mutates a little more on each save.
|
||||
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
|
||||
url.set_fragment(encoded_name.as_deref());
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
/// Split the query into recognized parameters and the names of the rest.
|
||||
///
|
||||
/// Unrecognized names are returned rather than rejected on the spot so the
|
||||
/// caller can report the *shape* problem first. A WebSocket URI always carries
|
||||
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
|
||||
/// instead of the transport sends the user deleting parameters when the real
|
||||
/// answer is that Donut only speaks plain TCP.
|
||||
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
|
||||
let mut parameters = HashMap::new();
|
||||
let mut unsupported = Vec::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
|
||||
return Err(XrayError::UnsupportedParameter(name.into_owned()));
|
||||
unsupported.push(name.into_owned());
|
||||
continue;
|
||||
}
|
||||
if parameters
|
||||
.insert(name.to_string(), value.into_owned())
|
||||
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
return Err(XrayError::DuplicateParameter(name.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(parameters)
|
||||
Ok((parameters, unsupported))
|
||||
}
|
||||
|
||||
fn required_parameter<'a>(
|
||||
@@ -230,6 +252,109 @@ mod tests {
|
||||
|
||||
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
|
||||
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
|
||||
/// server is a kind we do not support" rather than "you mistyped". These pin
|
||||
/// the reason each rejection reports, because the UI turns it into the one
|
||||
/// sentence that tells a user with a working WebSocket or plain-TLS server
|
||||
/// why Donut will not take it.
|
||||
#[test]
|
||||
fn unsupported_setups_report_which_part_is_unsupported() {
|
||||
let good = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
|
||||
);
|
||||
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
|
||||
|
||||
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
|
||||
|
||||
// Plain TLS instead of REALITY — the most common real-world setup.
|
||||
assert_eq!(
|
||||
reason(&good.replace("security=reality", "security=tls")),
|
||||
"security"
|
||||
);
|
||||
assert_eq!(
|
||||
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
|
||||
"flow"
|
||||
);
|
||||
// WebSocket / gRPC transports.
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
|
||||
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
|
||||
assert_eq!(
|
||||
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
|
||||
"publicKey"
|
||||
);
|
||||
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
|
||||
assert_eq!(reason("not a uri"), "malformed");
|
||||
}
|
||||
|
||||
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
|
||||
///
|
||||
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
|
||||
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
|
||||
/// before the shape was checked first they produced "unsupported option"
|
||||
/// and sent the user deleting query parameters instead of telling them
|
||||
/// Donut only speaks plain TCP.
|
||||
#[test]
|
||||
fn a_display_name_survives_an_export_parse_round_trip() {
|
||||
// Percent signs are legal in a fragment, so they used to pass through
|
||||
// unencoded and then get decoded on the way back in — "50% off" became
|
||||
// "50 off"-ish and drifted further on every canonicalizing save.
|
||||
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
|
||||
let parsed = parse_vless_uri(&format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
))
|
||||
.expect("baseline parses");
|
||||
|
||||
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
|
||||
let reparsed = parse_vless_uri(&exported).expect("re-parses");
|
||||
assert_eq!(
|
||||
reparsed.name.as_deref(),
|
||||
Some(name),
|
||||
"display name mutated across a round trip: {exported}"
|
||||
);
|
||||
|
||||
// And a second round trip must be a fixed point, not drift again.
|
||||
let exported_again =
|
||||
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
|
||||
assert_eq!(exported, exported_again);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_world_websocket_and_grpc_links_name_the_transport() {
|
||||
let ws = format!(
|
||||
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
|
||||
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&ws).unwrap_err().reason_code(),
|
||||
"transport",
|
||||
"a WebSocket link must be told its transport is unsupported"
|
||||
);
|
||||
|
||||
let grpc = format!(
|
||||
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
|
||||
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&grpc).unwrap_err().reason_code(),
|
||||
"transport"
|
||||
);
|
||||
|
||||
// A genuinely unknown option on an otherwise-supported URI still reports
|
||||
// as a parameter problem, which is the accurate answer there.
|
||||
let odd = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&odd).unwrap_err().reason_code(),
|
||||
"parameter"
|
||||
);
|
||||
}
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
|
||||
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let _start_guard = XRAY_START_LOCK.lock().await;
|
||||
parse_vless_uri(vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
ensure_xray_binary()?;
|
||||
let owner_pid = std::process::id();
|
||||
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
|
||||
save_xray_worker_config_to_path(&config, config_path)
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let parsed = parse_vless_uri(&config.vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
let runtime = XrayClientRuntime {
|
||||
listen_port: config.local_port,
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
};
|
||||
let runtime_json = build_client_config_json(&parsed.config, &runtime)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
|
||||
|
||||
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
|
||||
xray_worker_tombstone_path(id).exists()
|
||||
}
|
||||
|
||||
/// How long a tombstone has to outlive its worker.
|
||||
///
|
||||
/// It only has to survive long enough to beat a write already in flight from
|
||||
/// the process that owned that id. A day is many orders of magnitude more than
|
||||
/// that, and bounds a directory that otherwise gains a file per worker forever.
|
||||
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Drop tombstones old enough that nothing could still be racing them.
|
||||
fn prune_stale_tombstones() {
|
||||
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
|
||||
continue;
|
||||
}
|
||||
let aged_out = path
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| {
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|age| age > TOMBSTONE_TTL)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if aged_out {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
|
||||
ensure_private_storage_dir()?;
|
||||
if worker_is_tombstoned(id) {
|
||||
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
|
||||
// Cheap, and this is the one call every sweep already makes.
|
||||
prune_stale_tombstones();
|
||||
let storage_dir = crate::proxy_storage::get_storage_dir();
|
||||
let Ok(entries) = fs::read_dir(storage_dir) else {
|
||||
return Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user