mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-27 08:02:09 +08:00
fix(port): stop the mixed port climbing on every restart
The startup fallback persisted its replacement, so a port blocked once was never asked for again. It now overrides the Runtime Config for the session only. Most of those conflicts were not real: SO_REUSEADDR cannot reuse a TIME_WAIT socket owned by another user, and a service-managed core runs as root while the app does not. A refused bind is now confirmed by asking whether anything answers. Also restores the borrowed port when saving proxy ports fails.
This commit is contained in:
+1
-1
@@ -19,7 +19,7 @@
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
|
||||
- 新增混合代理端口冲突自动回退:启动时检测端口占用并临时改用可用端口,配置中的端口保持不变、下次启动重新尝试,同时统一首页、设置与系统代理中的生效端口显示
|
||||
- 系统代理或 TUN 因权限被拒时弹出引导窗口,一步安装系统服务并自动恢复原本的设置
|
||||
- 引导窗口显示当前阶段:安装服务 / 重启内核 / 应用代理
|
||||
- 系统代理失败会保留到窗口可见时再呈现,不会因窗口隐藏或最小化而丢失
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::{IClashTemp, IProfiles, IVerge};
|
||||
use super::{IClashTemp, IProfiles, IVerge, MixedPort};
|
||||
use crate::{
|
||||
config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime},
|
||||
constants::{files, timing},
|
||||
core::{
|
||||
CoreManager,
|
||||
handle::{self, Handle},
|
||||
listener::MIXED_PORT_KEY,
|
||||
tray,
|
||||
validate::CoreConfigValidator,
|
||||
},
|
||||
@@ -258,6 +259,11 @@ impl Config {
|
||||
let (mut config, exists_keys, logs) = enhance::enhance(profiles).await?;
|
||||
|
||||
sanitize_tunnels_proxy(&mut config);
|
||||
// Applied here rather than in the Merge Config: this is the only place the Core's config
|
||||
// is built, and `config.yaml` must stay unaware so the next launch asks for the original.
|
||||
if let Some(port) = MixedPort::session_fallback() {
|
||||
config.insert(MIXED_PORT_KEY.into(), port.into());
|
||||
}
|
||||
|
||||
Self::runtime().await.edit_draft(|d| {
|
||||
*d = IRuntime {
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
//! Resolving the Mixed Port.
|
||||
//!
|
||||
//! Two different questions were being asked with one answer:
|
||||
//!
|
||||
//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that
|
||||
//! writes configuration or decides what to start needs this, and it has to be answerable
|
||||
//! before the Core exists.
|
||||
//! - *What is the Core actually serving on?* — what the Core reports, else what we configured.
|
||||
//! Anything that points a client at the proxy wants this, because the two can differ: a
|
||||
//! startup fallback moves the port, and a hand-edited Core config can move it too.
|
||||
//!
|
||||
//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config
|
||||
//! entirely) and the frontend hand-copied a version of the second.
|
||||
//! Two questions, deliberately kept apart because the answers can differ:
|
||||
//! [`MixedPort::desired`] — what we configured, answerable before the Core exists;
|
||||
//! [`MixedPort::effective`] — what the Core reports it is serving on.
|
||||
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
@@ -20,33 +14,49 @@ use crate::core::handle::Handle;
|
||||
/// The port mihomo listens on when nothing else is configured.
|
||||
pub const DEFAULT_MIXED_PORT: u16 = 7897;
|
||||
|
||||
/// The port a startup fallback moved this session to, or 0 for none.
|
||||
///
|
||||
/// Never persisted: the user's port stays in `verge.yaml` and `config.yaml` so the next launch
|
||||
/// asks for it again. Persisting it instead made a once-blocked port climb on every launch.
|
||||
static SESSION_FALLBACK: AtomicU16 = AtomicU16::new(0);
|
||||
|
||||
/// Resolving the Mixed Port.
|
||||
pub struct MixedPort;
|
||||
|
||||
impl MixedPort {
|
||||
/// The Mixed Port the app has configured.
|
||||
/// Read by [`Self::desired`] and by runtime config generation — the two places that decide
|
||||
/// what the Core is asked to listen on.
|
||||
pub(crate) fn session_fallback() -> Option<u16> {
|
||||
match SESSION_FALLBACK.load(Ordering::Acquire) {
|
||||
0 => None,
|
||||
port => Some(port),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_session_fallback(port: u16) {
|
||||
SESSION_FALLBACK.store(port, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_session_fallback() {
|
||||
SESSION_FALLBACK.store(0, Ordering::Release);
|
||||
}
|
||||
|
||||
/// The Mixed Port the app has configured. Safe before the Core exists.
|
||||
///
|
||||
/// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe
|
||||
/// to call before the Core is running, unlike [`Self::effective`].
|
||||
///
|
||||
/// Reading the draft means a caller that hands this to something *outside* the app — the
|
||||
/// PAC endpoint is the one that does — can name a port the Core has not moved to yet.
|
||||
/// What makes that safe is that the only path staging a listener port,
|
||||
/// `feat::listener::save_proxy_ports`, closes PAC across the staging with `core_starting`
|
||||
/// and reopens it with `core_start_settled`. A second path that stages a listener port
|
||||
/// while the Core is serving would have to do the same.
|
||||
/// Reads the draft layer, so this can name a port the Core has not moved to yet — which
|
||||
/// matters because the PAC endpoint hands it outside the app. Safe only because
|
||||
/// `feat::listener::save_proxy_ports` closes PAC across the staging (`core_starting` /
|
||||
/// `core_start_settled`); any other path staging a listener port must do the same.
|
||||
pub async fn desired() -> u16 {
|
||||
let selected = Config::verge().await.latest_arc().verge_mixed_port;
|
||||
// `get_mixed_port` already falls back to the default when the Merge Config is silent.
|
||||
let merged = Config::clash().await.latest_arc().get_mixed_port();
|
||||
resolve_desired(selected, merged)
|
||||
resolve_desired(Self::session_fallback(), selected, merged)
|
||||
}
|
||||
|
||||
/// The Mixed Port the Core is actually serving on.
|
||||
///
|
||||
/// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on
|
||||
/// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked,
|
||||
/// which is the right answer whenever the Core is not running anyway.
|
||||
/// Costs a round-trip, so use it on user-triggered paths, not per-request ones.
|
||||
pub async fn effective() -> u16 {
|
||||
let desired = Self::desired().await;
|
||||
resolve_effective(
|
||||
@@ -57,11 +67,12 @@ impl MixedPort {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer what the user selected, else what the Merge Config resolved to.
|
||||
const fn resolve_desired(selected: Option<u16>, merged: u16) -> u16 {
|
||||
match selected {
|
||||
Some(port) => port,
|
||||
None => merged,
|
||||
/// Fallback first — it is the only value not written down, so the files still name the port the
|
||||
/// user asked for while this session serves another.
|
||||
const fn resolve_desired(session_fallback: Option<u16>, selected: Option<u16>, merged: u16) -> u16 {
|
||||
match (session_fallback, selected) {
|
||||
(Some(port), _) | (None, Some(port)) => port,
|
||||
(None, None) => merged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +96,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_selected_port_wins_over_the_merge_config() {
|
||||
assert_eq!(resolve_desired(Some(9000), 7897), 9000);
|
||||
assert_eq!(resolve_desired(None, Some(9000), 7897), 9000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_config_answers_when_nothing_is_selected() {
|
||||
assert_eq!(resolve_desired(None, 8080), 8080);
|
||||
assert_eq!(resolve_desired(None, None, 8080), 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_fallback_outranks_the_port_still_written_down() {
|
||||
assert_eq!(resolve_desired(Some(7900), Some(7897), 7897), 7900);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reporting_core_overrides_what_we_configured() {
|
||||
// This is the startup-fallback case: we asked for 7897 and the Core landed on 7898.
|
||||
assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use super::{
|
||||
Config, ConfigType, IClashTemp, IVerge,
|
||||
snapshot::{capture_config_files, restore_files},
|
||||
};
|
||||
use super::{Config, ConfigType, IClashTemp, IVerge, MixedPort};
|
||||
use crate::{
|
||||
constants::timing,
|
||||
core::{
|
||||
handle::Handle,
|
||||
listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys},
|
||||
listener::{ListenerBindScope, ListenerProbeOutcome, MIXED_PORT_KEY, proxy_listener_keys},
|
||||
owner_identity::current_owner_credentials,
|
||||
service::{SERVICE_MANAGER, ServiceStatus},
|
||||
validate::CoreConfigValidator,
|
||||
@@ -50,7 +47,9 @@ impl Config {
|
||||
let _config_write = Self::lock_config_write().await;
|
||||
let clash = Self::clash().await.latest_arc();
|
||||
let verge = Self::verge().await.latest_arc();
|
||||
let selected_port = clash.get_mixed_port();
|
||||
// A retry must re-examine the port this session actually tried, not the one still named
|
||||
// on disk, or it keeps picking the same replacement instead of walking past it.
|
||||
let selected_port = MixedPort::session_fallback().unwrap_or_else(|| clash.get_mixed_port());
|
||||
let bind_scope =
|
||||
ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?;
|
||||
|
||||
@@ -65,11 +64,24 @@ impl Config {
|
||||
}
|
||||
|
||||
let selected_scope = bind_scope.clone();
|
||||
let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port))
|
||||
let outcome = AsyncHandler::spawn_blocking(move || selected_scope.probe_mixed_port(selected_port))
|
||||
.await
|
||||
.context("mixed proxy port probe task failed")?;
|
||||
if !port_in_use {
|
||||
return Ok(false);
|
||||
// Only a proven conflict may move the port; an answer the probe could not reach is not
|
||||
// evidence. A `Conflict` here also means the probe confirmed something is serving.
|
||||
match outcome {
|
||||
ListenerProbeOutcome::Conflict { .. } => {}
|
||||
ListenerProbeOutcome::Available => return Ok(false),
|
||||
ListenerProbeOutcome::Invalid { message } | ListenerProbeOutcome::Indeterminate { message } => {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Setup,
|
||||
"Could not establish whether mixed proxy port {} is free ({}); keeping it",
|
||||
selected_port,
|
||||
message
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let reserved = configured_listener_ports(&clash, &verge);
|
||||
@@ -86,20 +98,41 @@ impl Config {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Move this session onto `new_port` without touching what is on disk.
|
||||
///
|
||||
/// Only the Runtime Configuration changes — the Core is its only reader and it is rebuilt
|
||||
/// every launch. `config.yaml` and `verge.yaml` keep naming `old_port` so the next launch
|
||||
/// asks for it again.
|
||||
async fn apply_startup_mixed_port_fallback(old_port: u16, new_port: u16) -> Result<()> {
|
||||
let clash = Self::clash().await;
|
||||
let verge = Self::verge().await;
|
||||
let runtime = Self::runtime().await;
|
||||
// Roll back all three drafts if fallback fails.
|
||||
let transaction = DraftTransaction::begin(vec![&clash, &verge, &runtime])?;
|
||||
let transaction = DraftTransaction::begin(vec![&runtime])?;
|
||||
|
||||
clash.edit_draft(|draft| {
|
||||
draft.0.insert(MIXED_PORT_KEY.into(), new_port.into());
|
||||
});
|
||||
verge.edit_draft(|draft| {
|
||||
draft.verge_mixed_port = Some(new_port);
|
||||
});
|
||||
// On a later hop the uncommitted transaction restores the Runtime Config to the previous
|
||||
// hop, so restore the override to match rather than dropping to what is on disk.
|
||||
let previous_port = MixedPort::session_fallback();
|
||||
MixedPort::set_session_fallback(new_port);
|
||||
if let Err(error) = Self::stage_fallback_runtime().await {
|
||||
match previous_port {
|
||||
Some(port) => MixedPort::set_session_fallback(port),
|
||||
None => MixedPort::clear_session_fallback(),
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
record_fallback(old_port, new_port);
|
||||
Handle::refresh_clash();
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Mixed proxy port {} is externally occupied; serving on {} for this session only",
|
||||
old_port,
|
||||
new_port
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stage_fallback_runtime() -> Result<()> {
|
||||
Self::generate()
|
||||
.await
|
||||
.context("failed to materialize runtime configuration with fallback port")?;
|
||||
@@ -112,46 +145,9 @@ impl Config {
|
||||
bail!("runtime configuration with fallback port is invalid: {validation}");
|
||||
}
|
||||
|
||||
let snapshots = capture_config_files().await?;
|
||||
let candidate_clash = clash.latest_arc();
|
||||
let candidate_verge = verge.latest_arc();
|
||||
|
||||
let persist_result = async {
|
||||
candidate_clash
|
||||
.save_config()
|
||||
.await
|
||||
.context("failed to persist Application Merge Configuration")?;
|
||||
candidate_verge
|
||||
.save_file()
|
||||
.await
|
||||
.context("failed to persist selected mixed proxy port")?;
|
||||
Self::generate_file(ConfigType::Run)
|
||||
.await
|
||||
.context("failed to persist Runtime Configuration")?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Restore files after rollback so regeneration sees committed values.
|
||||
if let Err(error) = persist_result {
|
||||
transaction.rollback();
|
||||
return match restore_files(&snapshots).await {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
|
||||
};
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
record_fallback(old_port, new_port);
|
||||
Handle::refresh_clash();
|
||||
Handle::refresh_verge();
|
||||
logging!(
|
||||
warn,
|
||||
Type::Config,
|
||||
"Mixed proxy port {} is externally occupied; switched to {} and persisted all configuration layers",
|
||||
old_port,
|
||||
new_port
|
||||
);
|
||||
Self::generate_file(ConfigType::Run)
|
||||
.await
|
||||
.context("failed to write Runtime Configuration")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -209,9 +205,16 @@ fn report_fallback_error(message: String) {
|
||||
});
|
||||
}
|
||||
|
||||
// Only a service-managed core can still be running during startup.
|
||||
// Only a service-managed core can still be running during startup. Every exit here decides
|
||||
// whether the app may renumber a port the user chose, so each one logs why.
|
||||
async fn owned_service_core_uses_port(port: u16) -> bool {
|
||||
if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
|
||||
let service_status = SERVICE_MANAGER.current().await;
|
||||
if !matches!(service_status, ServiceStatus::Ready) {
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
"Service is {service_status:?}, so no managed core can hold mixed proxy port {port}"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -238,9 +241,26 @@ async fn owned_service_core_uses_port(port: u16) -> bool {
|
||||
}
|
||||
};
|
||||
let Some(status) = response.data else {
|
||||
logging!(
|
||||
warn,
|
||||
Type::Service,
|
||||
"Service returned no status while checking mixed proxy port {port}"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
if response.code > 0 || !status.is_active || status.core_pid.is_none() {
|
||||
// Normal after a clean quit: the service drops the owner session and forgets the core, so
|
||||
// it can no longer vouch for whatever holds the port. This guard only covers dirty exits.
|
||||
logging!(
|
||||
info,
|
||||
Type::Service,
|
||||
"Service no longer tracks a core for this owner (code {}, active {}, pid {:?}); \
|
||||
mixed proxy port {} is unattributed",
|
||||
response.code,
|
||||
status.is_active,
|
||||
status.core_pid,
|
||||
port
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
#[cfg(target_os = "macos")]
|
||||
use clash_verge_logging::{Type as LogType, logging};
|
||||
#[cfg(target_os = "macos")]
|
||||
use network_interface::{NetworkInterface, NetworkInterfaceConfig as _};
|
||||
@@ -97,7 +96,7 @@ impl ListenerBindScope {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
|
||||
pub(crate) fn probe_mixed_port(&self, port: u16) -> ListenerProbeOutcome {
|
||||
let claims = self
|
||||
.addresses
|
||||
.iter()
|
||||
@@ -108,7 +107,13 @@ impl ListenerBindScope {
|
||||
.map(move |transport| BindClaim::new("mixed", address, port, transport))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
matches!(probe_claims(&claims), ListenerProbeOutcome::Available)
|
||||
probe_claims(&claims)
|
||||
}
|
||||
|
||||
/// Whether `port` is provably free — for picking a replacement, where anything short of
|
||||
/// proof should skip the candidate.
|
||||
pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
|
||||
matches!(self.probe_mixed_port(port), ListenerProbeOutcome::Available)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +340,58 @@ fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool {
|
||||
&& (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified())
|
||||
}
|
||||
|
||||
/// Whether a refused bind can be dismissed because nothing is actually serving the claim.
|
||||
///
|
||||
/// A refused bind is not proof a port is in use: `SO_REUSEADDR` cannot reuse a `TIME_WAIT` socket
|
||||
/// owned by a *different* user, and a service-managed Core runs as root while this process does
|
||||
/// not. So every port the Core used is briefly unbindable here even though the Core itself could
|
||||
/// rebind it at once (measured on macOS 26). "Is anything answering?" survives that asymmetry;
|
||||
/// "can I bind?" does not.
|
||||
///
|
||||
/// Restrictions, both deliberate: a wildcard claim can be blocked by a listener on any address, so
|
||||
/// one refused connection cannot clear it — leaving `allow-lan` setups exposed to the same false
|
||||
/// positive. UDP has no lingering state, so a refused UDP bind is always a live socket.
|
||||
fn nothing_is_serving(claim: &BindClaim) -> bool {
|
||||
const ANSWER_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200);
|
||||
|
||||
if claim.transport != ListenerTransport::Tcp || claim.address.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
// Only an explicit refusal proves absence; a timeout leaves the question open.
|
||||
matches!(
|
||||
std::net::TcpStream::connect_timeout(&claim.socket_addr(), ANSWER_TIMEOUT),
|
||||
Err(error) if error.kind() == io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
}
|
||||
|
||||
fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
|
||||
let mut sockets = Vec::with_capacity(claims.len());
|
||||
for claim in claims {
|
||||
match bind_claim(claim) {
|
||||
Ok(mut bound) => sockets.append(&mut bound),
|
||||
Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim),
|
||||
Err(error) if is_bind_conflict(&error) => {
|
||||
if nothing_is_serving(claim) {
|
||||
logging!(
|
||||
info,
|
||||
LogType::Network,
|
||||
"{} could not bind {} ({}), but nothing answers there; treating it as free",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logging!(
|
||||
warn,
|
||||
LogType::Network,
|
||||
"{} cannot claim {} over {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
transport_name(claim.transport),
|
||||
error
|
||||
);
|
||||
return conflict_outcome(claim);
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
@@ -370,7 +421,8 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome {
|
||||
}
|
||||
}
|
||||
}
|
||||
debug_assert!(sockets.len() >= claims.len());
|
||||
// No one-socket-per-claim assertion: a dismissed claim is one this process never bound.
|
||||
drop(sockets);
|
||||
ListenerProbeOutcome::Available
|
||||
}
|
||||
|
||||
@@ -425,7 +477,19 @@ fn bind_claim(claim: &BindClaim) -> io::Result<Vec<Socket>> {
|
||||
match bind_socket(&BindClaim::new(claim.name, address, claim.port, claim.transport)) {
|
||||
Ok(socket) => sockets.push(socket),
|
||||
// Only a conflict proves the port is taken; a vanished guard address proves nothing.
|
||||
Err(error) if is_bind_conflict(&error) => return Err(error),
|
||||
Err(error) if is_bind_conflict(&error) => {
|
||||
// The caller only sees the claim, so name the address that actually lost.
|
||||
logging!(
|
||||
warn,
|
||||
LogType::Network,
|
||||
"{} claiming {} could not hold the overlapping address {}: {}",
|
||||
claim.name,
|
||||
claim.socket_addr(),
|
||||
SocketAddr::new(address, claim.port),
|
||||
error
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -629,14 +693,14 @@ const fn transport_name(transport: ListenerTransport) -> &'static str {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener,
|
||||
probe_proxy_port_change,
|
||||
BindClaim, ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, nothing_is_serving,
|
||||
probe_listener, probe_proxy_port_change,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_yaml_ng::Mapping;
|
||||
#[cfg(unix)]
|
||||
use socket2::{Domain, Protocol, SockAddr, Socket, Type};
|
||||
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
|
||||
use std::net::{IpAddr, Ipv4Addr, TcpListener, UdpSocket};
|
||||
#[cfg(unix)]
|
||||
use std::{
|
||||
io::Read as _,
|
||||
@@ -718,6 +782,53 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Guards the startup false positive: another user's `TIME_WAIT` makes the kernel refuse the
|
||||
/// bind while nothing is serving. That refusal needs a second uid to stage, so this asserts
|
||||
/// on the classifier directly.
|
||||
#[test]
|
||||
fn a_refused_bind_with_nothing_answering_is_not_a_conflict() -> anyhow::Result<()> {
|
||||
let vacant = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = vacant.local_addr()?.port();
|
||||
drop(vacant);
|
||||
|
||||
assert!(
|
||||
nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Tcp
|
||||
)),
|
||||
"a port nobody listens on must be dismissable"
|
||||
);
|
||||
|
||||
let serving = TcpListener::bind((Ipv4Addr::LOCALHOST, port))?;
|
||||
assert!(
|
||||
!nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Tcp
|
||||
)),
|
||||
"a live listener must never be dismissed"
|
||||
);
|
||||
drop(serving);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// UDP has no lingering state, so a refused UDP bind is always a live socket.
|
||||
#[test]
|
||||
fn a_refused_udp_bind_is_never_dismissed() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
assert!(!nothing_is_serving(&BindClaim::new(
|
||||
"mixed",
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
port,
|
||||
ListenerTransport::Udp
|
||||
)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn probe_reuses_port_held_only_by_a_stale_tcp_connection() -> anyhow::Result<()> {
|
||||
@@ -848,6 +959,19 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The startup fallback only moves on a proven `Conflict`, so a taken port must report one.
|
||||
#[test]
|
||||
fn startup_scope_reports_a_conflict_rather_than_an_unreachable_answer() -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?;
|
||||
assert!(matches!(
|
||||
scope.probe_mixed_port(port),
|
||||
ListenerProbeOutcome::Conflict { .. }
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
config::{
|
||||
Config, ConfigType,
|
||||
Config, ConfigType, MixedPort,
|
||||
snapshot::{FileSnapshot, capture_config_files, restore_files},
|
||||
},
|
||||
core::{
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use scopeguard::{ScopeGuard, defer, guard};
|
||||
|
||||
pub async fn probe_listener(request: ListenerProbe) -> Result<ListenerProbeOutcome> {
|
||||
AsyncHandler::spawn_blocking(move || probe_listener_sync(&request))
|
||||
@@ -55,6 +55,11 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
defer! {
|
||||
manager.core_start_settled();
|
||||
}
|
||||
// A user-picked port ends the startup fallback, but only once the save lands: until then the
|
||||
// Core still serves the borrowed port, so every failing exit must put it back or the system
|
||||
// proxy and PAC point at a dead port. Declared after the PAC guard so it restores first.
|
||||
let borrowed_port = guard(MixedPort::session_fallback(), restore_borrowed_port);
|
||||
MixedPort::clear_session_fallback();
|
||||
Config::generate()
|
||||
.await
|
||||
.context("failed to generate candidate proxy port configuration")?;
|
||||
@@ -86,6 +91,9 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
|
||||
if was_running && let Err(activation_error) = manager.restart_core_during_config_update().await {
|
||||
transaction.rollback();
|
||||
// Ahead of the rollback, not on scope exit: restarting the old core re-applies the system
|
||||
// proxy from `MixedPort::desired()`, which must already name the borrowed port.
|
||||
restore_borrowed_port(*borrowed_port);
|
||||
if let Err(rollback_error) = rollback_proxy_ports(&snapshots, was_running).await {
|
||||
return Err(rollback_failure(activation_error, rollback_error)
|
||||
.context("failed to activate the proxy port configuration"));
|
||||
@@ -105,6 +113,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
|
||||
if let Err(persist_error) = persist_proxy_port_sources().await {
|
||||
transaction.rollback();
|
||||
restore_borrowed_port(*borrowed_port);
|
||||
return match rollback_proxy_ports(&snapshots, was_running).await {
|
||||
Ok(()) => Err(persist_error),
|
||||
Err(rollback_error) => Err(rollback_failure(persist_error, rollback_error)),
|
||||
@@ -112,12 +121,22 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
}
|
||||
|
||||
transaction.commit();
|
||||
// The save landed, so the port the app had borrowed is now irrelevant.
|
||||
let _ = ScopeGuard::into_inner(borrowed_port);
|
||||
Handle::refresh_clash();
|
||||
Handle::refresh_verge();
|
||||
logging!(info, Type::Config, "Proxy port configuration applied and persisted");
|
||||
Ok(SaveProxyPortsOutcome::Saved)
|
||||
}
|
||||
|
||||
/// Put back the port a startup fallback had borrowed, after a save failed to replace it.
|
||||
fn restore_borrowed_port(previous: Option<u16>) {
|
||||
match previous {
|
||||
Some(port) => MixedPort::set_session_fallback(port),
|
||||
None => MixedPort::clear_session_fallback(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn current_runtime_mapping() -> Result<serde_yaml_ng::Mapping> {
|
||||
Config::runtime()
|
||||
.await
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "المنفذ {{oldPort}} مستخدم. تم تبديل منفذ الوكيل المختلط إلى {{newPort}} وحفظه.",
|
||||
"automaticFallback": "المنفذ {{oldPort}} مستخدم، لذا تعمل هذه الجلسة على {{newPort}}. لم يتغيّر إعدادك وسيُعاد تجربة {{oldPort}} عند التشغيل التالي.",
|
||||
"automaticFallbackFailed": "فشل التبديل التلقائي لمنفذ الوكيل المختلط: {{error}}",
|
||||
"portTooLow": "يجب ألا يقل المنفذ عن 1000",
|
||||
"portTooHigh": "يجب ألا يزيد المنفذ عن 65535"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "Port {{oldPort}} ist belegt. Der gemischte Proxy-Port wurde auf {{newPort}} geändert und gespeichert.",
|
||||
"automaticFallback": "Port {{oldPort}} ist belegt, daher läuft diese Sitzung auf {{newPort}}. Die Einstellung bleibt unverändert und {{oldPort}} wird beim nächsten Start erneut versucht.",
|
||||
"automaticFallbackFailed": "Der automatische Wechsel des gemischten Proxy-Ports ist fehlgeschlagen: {{error}}",
|
||||
"portTooLow": "Der Port muss mindestens 1000 sein",
|
||||
"portTooHigh": "Der Port darf höchstens 65535 sein"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "Port {{oldPort}} was in use. The mixed proxy port was switched to {{newPort}} and saved.",
|
||||
"automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.",
|
||||
"automaticFallbackFailed": "Automatic mixed proxy port fallback failed: {{error}}",
|
||||
"portTooLow": "The port must be 1000 or higher",
|
||||
"portTooHigh": "The port must be 65535 or lower"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "El puerto {{oldPort}} está en uso. El puerto proxy mixto se cambió a {{newPort}} y se guardó.",
|
||||
"automaticFallback": "El puerto {{oldPort}} está en uso, por lo que esta sesión usa {{newPort}}. Tu configuración no ha cambiado y se volverá a intentar {{oldPort}} en el próximo inicio.",
|
||||
"automaticFallbackFailed": "Error al cambiar automáticamente el puerto proxy mixto: {{error}}",
|
||||
"portTooLow": "El puerto debe ser 1000 o superior",
|
||||
"portTooHigh": "El puerto debe ser 65535 o inferior"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "پورت {{oldPort}} در حال استفاده بود. پورت پراکسی ترکیبی به {{newPort}} تغییر کرد و ذخیره شد.",
|
||||
"automaticFallback": "پورت {{oldPort}} در حال استفاده بود، بنابراین این اجرا از {{newPort}} استفاده میکند. تنظیم شما تغییر نکرده و در اجرای بعدی {{oldPort}} دوباره امتحان میشود.",
|
||||
"automaticFallbackFailed": "تغییر خودکار پورت پراکسی ترکیبی ناموفق بود: {{error}}",
|
||||
"portTooLow": "پورت باید ۱۰۰۰ یا بیشتر باشد",
|
||||
"portTooHigh": "پورت باید ۶۵۵۳۵ یا کمتر باشد"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "Port {{oldPort}} sedang digunakan. Port proxy campuran dialihkan ke {{newPort}} dan disimpan.",
|
||||
"automaticFallback": "Port {{oldPort}} sedang digunakan, jadi sesi ini menggunakan {{newPort}}. Pengaturan Anda tidak berubah dan {{oldPort}} akan dicoba lagi saat mulai berikutnya.",
|
||||
"automaticFallbackFailed": "Pengalihan otomatis port proxy campuran gagal: {{error}}",
|
||||
"portTooLow": "Port harus 1000 atau lebih tinggi",
|
||||
"portTooHigh": "Port harus 65535 atau lebih rendah"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "ポート {{oldPort}} は使用中です。混合プロキシポートを {{newPort}} に切り替えて保存しました。",
|
||||
"automaticFallback": "ポート {{oldPort}} が使用中のため、今回は {{newPort}} で動作します。設定は変更されておらず、次回起動時に {{oldPort}} を再試行します。",
|
||||
"automaticFallbackFailed": "混合プロキシポートの自動切り替えに失敗しました: {{error}}",
|
||||
"portTooLow": "ポート番号は 1000 以上である必要があります",
|
||||
"portTooHigh": "ポート番号は 65535 以下である必要があります"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "포트 설정이 저장되었습니다",
|
||||
"saveFailed": "포트 설정 저장에 실패했습니다",
|
||||
"automaticFallback": "포트 {{oldPort}}이(가) 사용 중입니다. 혼합 프록시 포트를 {{newPort}}(으)로 전환하고 저장했습니다.",
|
||||
"automaticFallback": "포트 {{oldPort}}이(가) 사용 중이어서 이번 실행은 {{newPort}}을(를) 사용합니다. 설정은 변경되지 않았으며 다음 시작 시 {{oldPort}}을(를) 다시 시도합니다.",
|
||||
"automaticFallbackFailed": "혼합 프록시 포트 자동 전환에 실패했습니다: {{error}}",
|
||||
"portTooLow": "포트는 1000 이상이어야 합니다",
|
||||
"portTooHigh": "포트는 65535 이하여야 합니다"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Порт {{port}} уже используется",
|
||||
"saved": "Настройки портов сохранены",
|
||||
"saveFailed": "Не удалось сохранить настройки портов",
|
||||
"automaticFallback": "Порт {{oldPort}} занят. Смешанный прокси-порт переключён на {{newPort}} и сохранён.",
|
||||
"automaticFallback": "Порт {{oldPort}} занят, поэтому этот запуск использует {{newPort}}. Настройка не изменена, при следующем запуске {{oldPort}} будет опробован снова.",
|
||||
"automaticFallbackFailed": "Не удалось автоматически переключить смешанный прокси-порт: {{error}}",
|
||||
"portTooLow": "Порт должен быть не меньше 1000",
|
||||
"portTooHigh": "Порт должен быть не больше 65535"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "{{oldPort}} portu kullanımda. Karma proxy portu {{newPort}} olarak değiştirildi ve kaydedildi.",
|
||||
"automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.",
|
||||
"automaticFallbackFailed": "Karma proxy portu otomatik olarak değiştirilemedi: {{error}}",
|
||||
"portTooLow": "Bağlantı noktası 1000 veya daha yüksek olmalıdır",
|
||||
"portTooHigh": "Bağlantı noktası 65535 veya daha düşük olmalıdır"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "Port settings saved",
|
||||
"saveFailed": "Failed to save port settings",
|
||||
"automaticFallback": "Port {{oldPort}} was in use. The mixed proxy port was switched to {{newPort}} and saved.",
|
||||
"automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.",
|
||||
"automaticFallbackFailed": "Automatic mixed proxy port fallback failed: {{error}}",
|
||||
"portTooLow": "Порт 1000 дән ким булмаска тиеш",
|
||||
"portTooHigh": "Порт 65535 тән артмаска тиеш"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "端口 {{port}} 已被占用",
|
||||
"saved": "端口设置已保存",
|
||||
"saveFailed": "端口设置保存失败",
|
||||
"automaticFallback": "端口 {{oldPort}} 已被占用,混合代理端口已自动切换并保存为 {{newPort}}。",
|
||||
"automaticFallback": "端口 {{oldPort}} 已被占用,本次运行改用 {{newPort}}。设置未更改,下次启动会重新尝试 {{oldPort}}。",
|
||||
"automaticFallbackFailed": "混合代理端口自动切换失败:{{error}}",
|
||||
"portTooLow": "端口号不能小于 1000",
|
||||
"portTooHigh": "端口号不能大于 65535"
|
||||
|
||||
@@ -292,7 +292,7 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "連結埠設定已儲存",
|
||||
"saveFailed": "連結埠設定儲存失敗",
|
||||
"automaticFallback": "連結埠 {{oldPort}} 已被佔用,混合代理連結埠已自動切換並儲存為 {{newPort}}。",
|
||||
"automaticFallback": "連結埠 {{oldPort}} 已被佔用,本次執行改用 {{newPort}}。設定未變更,下次啟動會重新嘗試 {{oldPort}}。",
|
||||
"automaticFallbackFailed": "混合代理連結埠自動切換失敗:{{error}}",
|
||||
"portTooLow": "連結埠不能小於 1000",
|
||||
"portTooHigh": "連結埠不能大於 65535"
|
||||
|
||||
Reference in New Issue
Block a user