Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ already in progress"`. Cache the last `UpdateInfo` (never log asset URLs).
- Diagnostics accepts any pasted form (`https://www.rade.ir/`, `host:port`, IPv6) and reduces it with `extractHost` before calling `test_route`; the rule parser rejects URLs outright. The flow result offers the opposite move — Add to direct for a VPN host, Add to VPN for a direct one — and re-tests afterwards so the card shows the new routing.
- User route pins are **bidirectional** (ADR 0034): `DirectRulesDocument.rules` pins DIRECT and `vpn_rules` pins VPN, a host lives in exactly one list, and `RuleManager::pin` moves it. Pins store the PSL registrable root (private suffixes included); `api.shop.example.com` is `example.com`, `user.github.io` is not all of `github.io`. `pin` must not wait on DoH. Writers emit `+.example.com` and never copy `resolved_ips` into IP providers. Precedence in `RuleSet::decide` must match generated Mihomo rules — private/LAN first, then VPN pins, then DIRECT pins, then bundled Iran domains, then curated `iran-business-domains`, then `MATCH`. Never let a private, loopback, or CGNAT address onto the VPN list. Custom VPN providers and the curated catalog need helper `GENERATION_FILES` entries (nine files). Live apply when the stack is running or degraded; persist-only when stopped.
- `resources/rules/manifest.json` `commit` is the **upstream Chocolate4U** revision the rules came from, not a BiFlow commit. Building a `raw.githubusercontent.com/devlifeX/BiFlow/<commit>/…` URL from it always 404s and surfaces as "cloud rule download failed". Fetch snapshot files from the same branch the manifest itself is fetched from; the per-file SHA-256 in the manifest is what guarantees integrity, not the ref.
- Windows `.join("biflow/debug.log")` keeps the `/`, so Explorer `/select,C:\…\biflow/debug.log` is ignored and opens This PC. Join `biflow` and `debug.log` as separate components, normalize leftover `/` to `\`, and pass `/select,` with `raw_arg`. The same class of bug is `mihomo_file_name()` returning `"bin/mihomo.exe"` — join `bin` and the file name separately. Helper Install must not elevate `C:\ProgramData\iran-split\bin\iran-split-helper.exe`: a leftover copy `fs::copy`s onto itself and a GUI-subsystem helper leaves stderr empty. Elevate the packaged helper, skip same-file copy, write `ProgramData\iran-split\install.log` on failure, and use NSIS `$PROGRAMDATA` (not `$COMMONPROGRAMDATA`) for helper/Mihomo install destinations. NSIS `--staging-dir` must be `$LOCALAPPDATA\biflow\runtime\generations`, the same path in-app Install writes; `$PROGRAMDATA\iran-split\staging` leaves providers at `0 / 0`.
- Windows `.join("biflow/debug.log")` keeps the `/`, so Explorer `/select,C:\…\biflow/debug.log` is ignored and opens This PC. Join `biflow` and `debug.log` as separate components, normalize leftover `/` to `\`, and pass `/select,` with `raw_arg`. The same class of bug is `mihomo_file_name()` returning `"bin/mihomo.exe"` — join `bin` and the file name separately. Helper Install must not elevate `C:\ProgramData\iran-split\bin\iran-split-helper.exe`: a leftover copy `fs::copy`s onto itself and a GUI-subsystem helper leaves stderr empty. Elevate the packaged helper, skip same-file copy, write `ProgramData\iran-split\install.log` on failure, and use NSIS `$PROGRAMDATA` (not `$COMMONPROGRAMDATA`) for helper/Mihomo install destinations. Packaged Windows helper staging is `$PROGRAMDATA\iran-split\staging` (ADR 0064). NSIS `perMachine` `$LOCALAPPDATA` expands to `C:\ProgramData`, so `$LOCALAPPDATA\biflow\runtime\generations` recorded `C:\ProgramData\biflow\runtime\generations` while the desktop wrote under the user's LocalAppData. The elevated installer ignores a mismatched `--staging-dir`, grants Builtin\Users modify with `icacls`, and the desktop stages into `WindowsPaths.generation_staging_dir`.
- `Start-Process -ArgumentList @('--mihomo', 'C:\Program Files\…')` concatenates array entries without quoting, so clap sees `C:\Program` and `Files\…`, exits 2 inside `Arguments::parse()`, and never reaches `persist_install_error`. Pass one Windows-quoted command line. Use `try_parse()` and write a redacted clap kind (`unexpected argument`) to `install.log` before `error.exit()`.
- `schtasks /TR "\"exe\" --config \"file\""` stores one broken action: `/Create` and `/Run` return 0, the GUI-subsystem helper never starts, and the desktop times out with “installed but is not reachable yet” while every pipe open is `os error 2`. Register the task from UTF-16 XML with separate `Command` and `Arguments`, wait for `\\.\pipe\iran-split-helper-v1` before returning success, and persist `run_named_pipe` failures to `install.log`.
- Never probe a named pipe with `Path::exists()`. It calls `fs::metadata`, an NPFS object has no file attributes to return, and the check reports a healthy helper as missing — turning a working install into a 15s timeout. Open the pipe the way the desktop does and treat only `ERROR_FILE_NOT_FOUND` (2) as absent; a busy instance or a denied ACL still proves it exists. Related Task Scheduler footguns: `<AllowHardTerminate>false</AllowHardTerminate>` makes `schtasks /End` a no-op, so a reinstall then dies on `ERROR_SHARING_VIOLATION` copying over the running helper's own image; `schtasks` writes UTF-16 to a pipe (sometimes with no BOM), so `from_utf8_lossy` alone silently yields text nothing can be found in; and `install.log` is read back one line at a time, so every message written to it must be collapsed to one line first.
- Windows `register_runtime_generation` failing with `IO_FAILED … cannot find the file specified (os error 2)` means helper.toml's `staging_dir` is not where the app stages generations. That path is recorded once at install time from `dirs::data_local_dir()`, so launching the app **elevated** for the helper install records the administrator's `%LOCALAPPDATA%` and every later run as the normal user misses it — the Windows twin of the Linux `sudo` uid-0 bug. Reinstalling the helper from a non-elevated app rewrites the path. Never let `canonicalize` errors reach the client bare; name the staging root and the generation directory so one log line identifies the mismatch.
- Windows `register_runtime_generation` failing with `INVALID_GENERATION` / `cannot find the file specified (os error 2)` at `C:\ProgramData\biflow\runtime\generations` is the 4.2 NSIS `$LOCALAPPDATA` all-users expansion (ADR 0064), not a missing generation UUID. Do not point helper.toml at a user profile: an elevated Install records the administrator's `%LOCALAPPDATA%` and every later run as the normal user misses it — the Windows twin of the Linux `sudo` uid-0 bug. Reinstalling the 4.3 helper rewrites staging to `C:\ProgramData\iran-split\staging` and the ACL. Never let `canonicalize` errors reach the client bare; name the staging root and the generation directory so one log line identifies the mismatch.
- `RuleManager::pin` must not wait on DoH. Resolve remains metadata-only on `refresh()`. A live apply rebuilds the generation and starts Mihomo on it; success is persist plus apply when the stack is running or degraded.
- The connection glow rings the shell from `.connection-glow::after` (a fixed, `pointer-events: none` overlay), never the shell's own border — a real border shifts the fixed 1120x760 layout. `running` is green, `paused` amber, every other phase unringed; `data-connection-glow` carries the state so e2e can assert it without reading colours. The pulse is disabled under `prefers-reduced-motion`.
- `install.log` is the only channel out of the elevated Windows helper (`Start-Process -Verb RunAs` cannot redirect stdio and the helper is a `windows` subsystem binary), so the desktop must delete it **before** elevating. The helper overwrites it only when it reaches its own error path; a process that dies earlier leaves the previous attempt's line behind, and reporting a stale reason is worse than reporting none.
Expand Down
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ members = [
]

[workspace.package]
version = "4.2.0"
version = "4.3.0"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.88"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@iran-split/desktop",
"version": "4.2.0",
"version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
27 changes: 27 additions & 0 deletions crates/iran-split-helper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,28 @@ tun_name = "clash-iran"
delete_owned_interface("unused");
}

#[cfg(windows)]
#[test]
fn windows_production_staging_is_beside_runtime_not_inside_it() {
let settings = HelperSettings {
authorized_uid: 0,
authorized_gid: 0,
socket_path: r"\\.\pipe\iran-split-helper-v1".into(),
staging_dir: r"C:\ProgramData\iran-split\staging".into(),
runtime_dir: r"C:\ProgramData\iran-split\runtime".into(),
mihomo_binary: r"C:\ProgramData\iran-split\bin\mihomo.exe".into(),
mihomo_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.into(),
tun_name: "clash-iran".into(),
};
assert!(settings.validate().is_ok());
let nested = HelperSettings {
staging_dir: r"C:\ProgramData\iran-split\runtime\generations".into(),
..settings
};
assert!(nested.validate().is_err());
}

#[cfg(unix)]
#[test]
fn production_linux_helper_paths_are_absolute() {
Expand Down Expand Up @@ -972,6 +994,11 @@ tun_name = "clash-iran"
assert!(source.contains("wait_until_pipe_ready"));
assert!(source.contains("wintun.dll"));
assert!(!source.contains("\"/TR\""));
assert!(source.contains(r#"root.join("staging")"#));
assert!(source.contains("fn grant_users_modify"));
assert!(source.contains("*S-1-5-32-545:(OI)(CI)M"));
assert!(source.contains("icacls"));
assert!(!source.contains("LOCALAPPDATA"));
}

/// `fs::metadata` fails on an NPFS object, so an `exists` check would report
Expand Down
57 changes: 54 additions & 3 deletions crates/iran-split-helper/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ use tracing::{info, warn};
const PIPE_NAME: &str = r"\\.\pipe\iran-split-helper-v1";
const INSTALL_ROOT: &str = r"C:\ProgramData\iran-split";
const INSTALL_LOG: &str = r"C:\ProgramData\iran-split\install.log";
/// Builtin\Users. Medium-integrity desktop processes can then write generations
/// that the SYSTEM helper later publishes. `(OI)(CI)M` is modify, inherited.
const USERS_MODIFY_ACE: &str = "*S-1-5-32-545:(OI)(CI)M";
const TASK_NAME: &str = "BiFlowHelper";
const PIPE_READY_TIMEOUT: Duration = Duration::from_secs(15);
const PIPE_READY_POLL: Duration = Duration::from_millis(100);
Expand Down Expand Up @@ -106,7 +109,7 @@ pub fn install(

fn install_inner(
mihomo_src: &Path,
staging_dir: &Path,
requested_staging_dir: &Path,
tun_name: &str,
) -> Result<(), HelperServiceError> {
let helper_src = std::env::current_exe()?;
Expand All @@ -115,9 +118,26 @@ fn install_inner(
let helper_dest = bin.join("iran-split-helper.exe");
let mihomo_dest = bin.join("mihomo.exe");
let config_dest = root.join("helper.toml");
// NSIS perMachine `SetShellVarContext all` makes the local-appdata shell
// variable expand to `C:\ProgramData`, and an elevated in-app Install can
// record an admin profile. The helper always stages beside `runtime`, never
// inside a user profile and never inside `runtime_dir` (those two must not
// nest).
let staging_dir = root.join("staging");
if requested_staging_dir != staging_dir {
warn!(
event = "helper.staging_dir_overridden",
section = "helper_install",
initiator = "elevated_installer",
cause = "machine_wide_staging",
trace_route = "elevated_helper->helper.toml",
"Windows helper staging is always ProgramData\\iran-split\\staging"
);
}
fs::create_dir_all(&bin)?;
fs::create_dir_all(root.join("runtime"))?;
fs::create_dir_all(staging_dir)?;
fs::create_dir_all(&staging_dir)?;
grant_users_modify(&staging_dir)?;
stop_previous_helper();
super::copy_file_unless_same(&helper_src, &helper_dest)?;
super::copy_file_unless_same(mihomo_src, &mihomo_dest)?;
Expand All @@ -131,7 +151,7 @@ fn install_inner(
authorized_uid: 0,
authorized_gid: 0,
socket_path: PathBuf::from(PIPE_NAME),
staging_dir: staging_dir.to_path_buf(),
staging_dir,
runtime_dir: root.join("runtime"),
mihomo_binary: mihomo_dest,
mihomo_sha256,
Expand Down Expand Up @@ -162,6 +182,37 @@ fn install_inner(
Ok(())
}

/// Lets the unelevated desktop write generation files that SYSTEM later copies
/// into `runtime_dir`. `icacls /grant` adds Builtin\Users modify without
/// replacing SYSTEM or Administrators inherited from `ProgramData`.
fn grant_users_modify(path: &Path) -> Result<(), HelperServiceError> {
let output = Command::new("icacls")
.arg(path)
.arg("/grant")
.arg(USERS_MODIFY_ACE)
.creation_flags(CREATE_NO_WINDOW)
.output()?;
if output.status.success() {
info!(
event = "helper.staging_acl_granted",
section = "helper_install",
initiator = "elevated_installer",
cause = "users_modify",
trace_route = "elevated_helper->staging_acl",
"Users can write helper staging generations"
);
return Ok(());
}
let mut detail = super::single_line(&super::decode_console_output(&output.stderr));
if detail.is_empty() {
detail = super::single_line(&super::decode_console_output(&output.stdout));
}
Err(HelperServiceError::Install(format!(
"could not grant Users modify on the helper staging directory: {}",
detail.chars().take(300).collect::<String>()
)))
}

fn register_and_start_task(helper: &Path, config: &Path) -> Result<(), HelperServiceError> {
let xml_path = PathBuf::from(INSTALL_ROOT).join("helper-task.xml");
write_utf16_le_bom(&xml_path, &super::scheduled_task_xml(helper, config))?;
Expand Down
30 changes: 24 additions & 6 deletions crates/iran-split-platform-win/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ pub struct WindowsPaths {
pub pipe_name: String,
pub user_data_dir: PathBuf,
pub system_runtime_dir: PathBuf,
/// Directory the SYSTEM helper reads in `register_runtime_generation`.
/// Packaged installs use `C:\ProgramData\iran-split\staging` so NSIS and
/// the unelevated desktop share one machine-wide root (ADR 0064).
pub generation_staging_dir: PathBuf,
pub resources_dir: PathBuf,
pub rules_cache_dir: PathBuf,
pub mihomo_binary: PathBuf,
Expand Down Expand Up @@ -699,9 +703,7 @@ impl PlatformBackend for WindowsBackend {
let generation_id = Uuid::new_v4();
let staging_root = self
.paths
.user_data_dir
.join("runtime")
.join("generations")
.generation_staging_dir
.join(generation_id.to_string());
fs::create_dir_all(&staging_root).map_err(|error| platform_error(&error))?;
let runtime_paths = RuntimePaths {
Expand Down Expand Up @@ -746,6 +748,15 @@ impl PlatformBackend for WindowsBackend {
generation: generation.clone(),
config_path: staging_root.join("config.yaml"),
});
info!(
event = "runtime.generation_prepared",
section = "runtime_generation",
initiator = "windows_platform_backend",
cause = "stack_start",
trace_id = %generation_id,
trace_route = "desktop_engine->windows_platform_backend->prepare_runtime",
"runtime generation staged for the helper"
);
Ok(generation)
}

Expand Down Expand Up @@ -1138,6 +1149,7 @@ mod tests {
pipe_name: HELPER_PIPE.to_owned(),
user_data_dir: root.join("user-data"),
system_runtime_dir: PathBuf::from(r"C:\ProgramData\iran-split\runtime"),
generation_staging_dir: root.join("staging"),
resources_dir: resources,
rules_cache_dir: root.join("rules-cache"),
mihomo_binary: root.join("mihomo.exe"),
Expand All @@ -1150,6 +1162,14 @@ mod tests {
assert!(!HELPER_PIPE.contains(".."));
}

#[test]
fn packaged_connect_stages_into_the_helper_generation_root() {
let source = include_str!("lib.rs");
assert!(source.contains("generation_staging_dir"));
assert!(source.contains(".generation_staging_dir"));
assert!(!source.contains(r#"user_data_dir.join("runtime").join("generations")"#));
}

#[test]
fn detects_busy_pipe_errors_for_bounded_retries() {
assert!(is_pipe_busy(&io::Error::from_raw_os_error(231)));
Expand Down Expand Up @@ -1205,9 +1225,7 @@ mod tests {
let backend = WindowsBackend::new(AppConfig::default(), paths.clone());
let generation = backend.prepare_runtime().await.expect("prepare");
let root = paths
.user_data_dir
.join("runtime")
.join("generations")
.generation_staging_dir
.join(generation.generation_id.to_string());
let names = fs::read_dir(&root)
.expect("generation")
Expand Down
10 changes: 5 additions & 5 deletions docs/adr/0033-windows-platform-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ attempts across three sessions each failed instantly at `ensure_hiddify`.
is synchronous, so the retry loop lives inside a timeout and retries only
`ERROR_PIPE_BUSY`. A `NotFound` / `ConnectionRefused` / timeout on the pipe
reports an uninstalled helper rather than an error banner.
- Runtime generations stage into `<data>/runtime/generations/<id>` — the same
root the elevated installer records as `staging_dir` in `helper.toml`, so a
generation staged by the app is the one SYSTEM is allowed to publish.
`generate_config` is called with `Platform::Windows`, which is what sets
`strict-route`.
- Runtime generations for packaged Windows stage into
`C:\ProgramData\iran-split\staging\<id>` — the same root the elevated
installer records as `staging_dir` in `helper.toml` (ADR 0064). Tests pass a
temp `generation_staging_dir`. `generate_config` is called with
`Platform::Windows`, which is what sets `strict-route`.
- TUN state is read from Mihomo's own `/configs`, not from the adapter list.
Enumerating adapters needs `GetAdaptersAddresses`, and this crate is under the
workspace `unsafe_code = "forbid"`; Mihomo owns the Wintun adapter, so its
Expand Down
Loading
Loading