Compare commits

...

4 Commits

Author SHA1 Message Date
ospab f5a1c17679 chore: release v0.4.5-beta.5 on beta 2026-08-11 23:31:11 +03:00
ospab 732d0bf5ae fix(installer): grant users permission to start the helper task
The task was registered correctly and pointed at the right binary — the app's
own log confirmed the match — but starting it failed:

  run: schtasks /Run failed (Some(1)):  ERROR: Access is denied.
  falling back to a direct elevated launch — this is the consent prompt

Registering a task and being allowed to start one are separate things, and I
had conflated them. The principal (BUILTIN\Users by SID, HighestAvailable)
decides who the task runs AS. Who may START it comes from the task's security
descriptor, and a task created by an elevated installer defaults to granting
execution to Administrators only. So the unprivileged GUI was refused and fell
back to prompting on every connect, exactly as before the installer existed.

This also explains why manual testing said the opposite: running the task by
hand happened from an elevated console, where it works, which pointed suspicion
at the app for several rounds.

Register-ScheduledTask cannot set a descriptor, so the hook now follows the
registration with a SetSecurityDescriptor call through the Task Scheduler COM
object: GA for Administrators and SYSTEM, GR+GX for BUILTIN\Users. A failure
there is reported on its own rather than being folded into the success message,
since the task would otherwise look registered while remaining unusable.
2026-08-11 23:28:36 +03:00
ospab 2887f1af8d chore: release v0.4.5-beta.4 on beta 2026-08-11 21:33:14 +03:00
ospab b18852ac07 fix(gui): relax the helper-task check, and make its decision observable
The installer's task is correct on the reporting machine — right path, right
principal, and running it by hand starts the helper with no prompt — yet the
app still fell back to an elevated launch on every connect. The exact-path
comparison is the only thing that can reject it, and it was never worth its
strictness: what the check exists to catch is a task left pointing at a binary
that is gone, since `schtasks /Run` reports success merely for accepting such a
request and the app would then wait on a helper that never starts. Testing that
the registered file exists and is the helper catches exactly that case, without
charging a prompt for any other difference.

The reason this took several rounds to narrow down is the real defect: every
failure on this path went to `eprintln!`, and the GUI is a windowed binary with
no console, so the one decision that determines whether the user gets a consent
prompt was completely unobservable on their machine. It now appends to
%LOCALAPPDATA%\OSTP\helper-launch.log — what was registered, whether it exists,
what schtasks /Run answered, and whether the fallback was taken.
2026-08-11 21:33:05 +03:00
4 changed files with 73 additions and 17 deletions

View File

@ -2,5 +2,5 @@
"target_version": "0.4.5", "target_version": "0.4.5",
"branch": "beta", "branch": "beta",
"alpha_iteration": 0, "alpha_iteration": 0,
"beta_iteration": 3 "beta_iteration": 5
} }

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.4.5+34 version: 0.4.5+36
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4

View File

@ -962,19 +962,47 @@ fn helper_task_command() -> Option<String> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn helper_task_matches(exe: &std::path::Path) -> bool { fn helper_task_matches(exe: &std::path::Path) -> bool {
let Some(registered) = helper_task_command() else { let Some(registered) = helper_task_command() else {
diag_log("task: schtasks /Query returned nothing usable — no task, or its XML had no <Command>");
return false; return false;
}; };
let registered = registered.trim().trim_matches('"'); let registered = registered.trim().trim_matches('"');
let path = std::path::Path::new(registered);
// Canonicalize both sides when possible so `..`, short 8.3 names and // Requiring the registered path to equal the helper we would have launched
// casing differences do not read as a mismatch. A missing file cannot be // was too strict, and bought nothing. What the check exists to catch is a
// canonicalized — which is itself a mismatch worth re-registering over. // task left pointing at a binary that is gone — `schtasks /Run` reports
match ( // success merely for accepting such a request, so the app would then wait
std::fs::canonicalize(registered), // on a helper that never starts. Testing that the file exists catches
std::fs::canonicalize(exe), // exactly that, while a task registered by the installer against an
) { // equivalent copy of the helper no longer costs the user a prompt.
(Ok(a), Ok(b)) => a == b, let same_program = path
_ => registered.eq_ignore_ascii_case(&exe.display().to_string()), .file_name()
.map(|n| n.eq_ignore_ascii_case(HELPER_EXE_NAME))
.unwrap_or(false);
let exists = path.is_file();
diag_log(&format!(
"task: registered={registered:?} exists={exists} same_program={same_program} wanted={:?}",
exe.display().to_string()
));
exists && same_program
}
/// Appends a line to a small log beside the helper's argument file.
///
/// The GUI is a windowed binary with no console, so every `eprintln!` on this
/// path went nowhere — which left the one decision that matters, whether the
/// scheduled task gets used or the user gets a consent prompt, completely
/// unobservable from a user's machine.
#[cfg(target_os = "windows")]
fn diag_log(msg: &str) {
let path = helper_args_file().with_file_name("helper-launch.log");
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
use std::io::Write;
let _ = writeln!(f, "{msg}");
} }
} }
@ -1004,18 +1032,24 @@ fn launch_as_admin(exe: &std::path::PathBuf, token: &str, port: u16) -> anyhow::
.args(["/Run", "/TN", HELPER_TASK_NAME]) .args(["/Run", "/TN", HELPER_TASK_NAME])
.output(); .output();
match run { match run {
Ok(o) if o.status.success() => return Ok(()), Ok(o) if o.status.success() => {
Ok(o) => eprintln!( diag_log("run: schtasks /Run accepted — no consent prompt");
"[OSTP] schtasks /Run failed: {}", return Ok(());
}
Ok(o) => diag_log(&format!(
"run: schtasks /Run failed ({:?}): {} {}",
o.status.code(),
String::from_utf8_lossy(&o.stdout).trim(),
String::from_utf8_lossy(&o.stderr).trim() String::from_utf8_lossy(&o.stderr).trim()
), )),
Err(e) => eprintln!("[OSTP] schtasks /Run could not start: {e}"), Err(e) => diag_log(&format!("run: schtasks /Run could not start: {e}")),
} }
} }
// Falling through: remove the file so a stale token is not left behind. // Falling through: remove the file so a stale token is not left behind.
let _ = std::fs::remove_file(&args_file); let _ = std::fs::remove_file(&args_file);
} }
diag_log("falling back to a direct elevated launch — this is the consent prompt");
launch_as_admin_direct(exe, token, port) launch_as_admin_direct(exe, token, port)
} }

View File

@ -49,7 +49,29 @@
Pop $R0 Pop $R0
${If} $R0 == 0 ${If} $R0 == 0
; Registering the task is not enough to make it usable. The principal above
; decides WHO THE TASK RUNS AS; the task's security descriptor decides who
; is allowed to START it, and they are not the same thing. A task created by
; an elevated installer defaults to a DACL granting execution to
; Administrators only, so the unprivileged GUI got
; schtasks /Run -> ERROR: Access is denied
; and fell back to prompting on every single connect. Running it by hand
; from an elevated console worked, which is what made this look for a while
; like the app was at fault.
;
; Register-ScheduledTask cannot set a descriptor, so this goes through the
; Task Scheduler COM object. GA for Administrators and SYSTEM, GR+GX —
; read and execute — for BUILTIN\Users (BU), which is what lets a normal
; user start it without being elevated.
DetailPrint "Granting users permission to start the task..."
nsExec::ExecToLog `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$svc = New-Object -ComObject Schedule.Service; $$svc.Connect(); $$t = $$svc.GetFolder('\').GetTask('OSTP TUN Helper'); $$t.SetSecurityDescriptor('D:(A;;GA;;;BA)(A;;GA;;;SY)(A;;GRGX;;;BU)', 0)"`
Pop $R1
${If} $R1 == 0
DetailPrint "Helper task registered; connecting will not ask for consent." DetailPrint "Helper task registered; connecting will not ask for consent."
${Else}
DetailPrint "Task registered but its permissions could not be set (exit $R1)."
DetailPrint "Every connect will ask for consent."
${EndIf}
${Else} ${Else}
; Not fatal: the app still works, it just falls back to an elevated launch ; Not fatal: the app still works, it just falls back to an elevated launch
; that asks for consent on each connect. ; that asks for consent on each connect.