diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..fde6b06 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# Windows: link the C runtime statically, so the agent and the tray start on a +# machine that has no Visual C++ redistributable installed. The installer ships +# nothing but our own files and Wintun. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 02bbf31..8b77a6b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -190,6 +190,29 @@ jobs: tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" fi + # The Windows installer: the agent as a service, Wintun, the tray and the + # PATH entry in one setup program. Wintun is the signed library from + # wintun.net, pinned by hash so a changed download fails the build. + - name: Build the Windows installer + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = $env:GITHUB_REF_NAME.TrimStart('v') + Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip + $hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash + if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') { + throw "wintun.zip has an unexpected hash: $hash" + } + Expand-Archive wintun.zip -DestinationPath wintun-dl + choco install innosetup -y --no-progress + $iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe' + $stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}' + & $iscc "/DAppVersion=$version" "/DSourceDir=$stage" ` + "/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" ` + "/O$PWD" dist/windows/tsunagi.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" } + # A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # the static binary, the systemd service and the polkit rule. Built with # dpkg-deb (present on the runner); see dist/deb/build-deb.sh. @@ -251,6 +274,7 @@ jobs: ${{ matrix.asset }}-*.* *.deb *.pkg.tar.zst + tsunagi-setup-*.exe if-no-files-found: error publish: @@ -322,5 +346,5 @@ jobs: cat up.json >&2 || true rc=1 fi - done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0) + done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \) -print0) exit "$rc" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89f17c8..841da9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -192,6 +192,29 @@ jobs: tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}" fi + # The Windows installer: the agent as a service, Wintun, the tray and the + # PATH entry in one setup program. Wintun is the signed library from + # wintun.net, pinned by hash so a changed download fails the build. + - name: Build the Windows installer + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = $env:GITHUB_REF_NAME.TrimStart('v') + Invoke-WebRequest -Uri https://www.wintun.net/builds/wintun-0.14.1.zip -OutFile wintun.zip + $hash = (Get-FileHash wintun.zip -Algorithm SHA256).Hash + if ($hash -ne '07C256185D6EE3652E09FA55C0B673E2624B565E02C4B9091C79CA7D2F24EF51') { + throw "wintun.zip has an unexpected hash: $hash" + } + Expand-Archive wintun.zip -DestinationPath wintun-dl + choco install innosetup -y --no-progress + $iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe' + $stage = Join-Path $PWD 'dist/pkg/${{ matrix.asset }}' + & $iscc "/DAppVersion=$version" "/DSourceDir=$stage" ` + "/DWintunDir=$(Join-Path $PWD 'wintun-dl/wintun/bin/amd64')" ` + "/O$PWD" dist/windows/tsunagi.iss + if ($LASTEXITCODE -ne 0) { throw "ISCC failed with $LASTEXITCODE" } + # A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops # the static binary, the systemd service and the polkit rule. Built with # dpkg-deb (present on the runner); see dist/deb/build-deb.sh. @@ -250,6 +273,7 @@ jobs: ${{ matrix.asset }}-*.* *.deb *.pkg.tar.zst + tsunagi-setup-*.exe if-no-files-found: error publish: @@ -278,7 +302,7 @@ jobs: # download-artifact nests each artifact under its own directory, so # the archives are artifacts//. - mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \)) + mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' -o -name 'tsunagi-setup-*.exe' \)) printf 'asset: %s\n' "${files[@]}" prerelease=() diff --git a/Cargo.lock b/Cargo.lock index 1529e67..7631e5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6250,6 +6250,7 @@ dependencies = [ "tsunagi-tcp-tls", "tsunagi-wg-quic", "tsunagi-wireguard", + "windows-service", ] [[package]] @@ -7135,6 +7136,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-service" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2" +dependencies = [ + "bitflags 2.13.2", + "widestring", + "windows-sys 0.61.2", +] + [[package]] name = "windows-strings" version = "0.1.0" diff --git a/crates/tsunagi/Cargo.toml b/crates/tsunagi/Cargo.toml index db16a93..461e603 100644 --- a/crates/tsunagi/Cargo.toml +++ b/crates/tsunagi/Cargo.toml @@ -34,6 +34,9 @@ netwatch = "0.19.3" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["user"] } +[target."cfg(windows)".dependencies] +windows-service = "0.8" + [dev-dependencies] tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] } tempfile.workspace = true diff --git a/crates/tsunagi/src/main.rs b/crates/tsunagi/src/main.rs index 1feeb4e..5b2d7cc 100644 --- a/crates/tsunagi/src/main.rs +++ b/crates/tsunagi/src/main.rs @@ -11,6 +11,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +#[cfg(windows)] +mod winservice; + use clap::{Args, Parser, Subcommand, ValueEnum}; use tsunagi::agent::Event; use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; @@ -84,6 +87,10 @@ enum Command { ExitNode(ExitNodeArgs), /// Removes everything this device has stored and starts over. Wipe(WipeArgs), + /// Runs the agent under the Windows service manager. Registered by the + /// installer, not typed. + #[command(hide = true)] + Service(Box), } #[derive(Debug, Args)] @@ -757,12 +764,26 @@ fn main() -> std::process::ExitCode { let is_debug = log_filter.contains("debug") || log_filter.contains("trace"); - tracing_subscriber::fmt() + let builder = tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::new(&log_filter)) .with_target(is_debug) - .compact() - .with_writer(std::io::stderr) - .init(); + .compact(); + // A service has no console: it writes to a file. + match matches!(cli.command, Command::Service(_)) + .then(open_service_log) + .flatten() + { + Some(file) => builder + .with_ansi(false) + .with_writer(std::sync::Mutex::new(file)) + .init(), + None => builder.with_writer(std::io::stderr).init(), + } + + #[cfg(windows)] + if let Command::Service(args) = cli.command { + return run_service(*args); + } // The library never starts a runtime; this binary owns it. let runtime = match tokio::runtime::Builder::new_multi_thread() @@ -785,6 +806,89 @@ fn main() -> std::process::ExitCode { } } +/// The log file of the Windows service, `%ProgramData%\Tsunagi\logs\tsng.log`. +/// +/// The previous one is kept as `tsng.log.old` once it passes a few megabytes, +/// so a service that logs for months does not fill the disk. `None` anywhere +/// else, and when the file cannot be opened. +fn open_service_log() -> Option { + if !cfg!(windows) { + return None; + } + let root = std::env::var_os("ProgramData")?; + open_log_in(&PathBuf::from(root).join("Tsunagi").join("logs")) +} + +/// Opens `tsng.log` in a directory for appending, moving a large one aside. +fn open_log_in(dir: &std::path::Path) -> Option { + std::fs::create_dir_all(dir).ok()?; + let log = dir.join("tsng.log"); + if std::fs::metadata(&log).is_ok_and(|meta| meta.len() > SERVICE_LOG_LIMIT) { + let _ = std::fs::rename(&log, dir.join("tsng.log.old")); + } + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .ok() +} + +/// Past this size the service log is moved aside, keeping one older file. +const SERVICE_LOG_LIMIT: u64 = 8 * 1024 * 1024; + +/// The directories and control socket of the Windows service, unless the +/// service command line names its own. +/// +/// A service runs as the system account, whose profile is the wrong home for +/// a device's identity, so the state is kept under `%ProgramData%\Tsunagi`, +/// where the installer locks it to administrators and the system. The control +/// socket stays at the system-wide default every client already looks at, +/// which is what lets a tray and an unelevated `tsng status` find the service. +#[cfg_attr(not(windows), allow(dead_code))] +fn service_defaults(mut args: UpArgs, program_data: &std::path::Path) -> UpArgs { + let root = program_data.join("Tsunagi"); + args.paths + .state_dir + .get_or_insert_with(|| root.join("data")); + args.paths + .cache_dir + .get_or_insert_with(|| root.join("cache")); + args.control_socket + .get_or_insert_with(tsunagi::ipc::default_control_socket_path); + args +} + +/// Hands the process to the Windows service manager, with `up` as the body. +#[cfg(windows)] +fn run_service(args: UpArgs) -> std::process::ExitCode { + let program_data = std::env::var_os("ProgramData") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + let args = service_defaults(args, &program_data); + let args = std::sync::Mutex::new(Some(args)); + let body = move || -> Result<(), String> { + let Some(args) = args.lock().ok().and_then(|mut slot| slot.take()) else { + return Err("the service was started twice".to_string()); + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| format!("cannot start the async runtime: {err}"))?; + runtime.block_on(up(args)).map_err(|err| err.to_string()) + }; + match winservice::run(Box::new(body)) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(err) => { + eprintln!( + "error: cannot run as a service ({err}); `tsng service` is started by the \ + Windows service manager, use `tsng up` to run the agent yourself" + ); + std::process::ExitCode::FAILURE + } + } +} + async fn run(command: Command) -> Result<(), Box> { match command { Command::Id(args) => id(args).await, @@ -796,6 +900,11 @@ async fn run(command: Command) -> Result<(), Box> { Command::Dns(args) => dns_command(args).await, Command::ExitNode(args) => exit_node_command(args).await, Command::Wipe(args) => wipe(args).await, + // Taken in `main`, before there is a runtime: the service manager + // wants the process's thread, not an async task. + Command::Service(_) => Err("`tsng service` is started by the Windows service \ + manager; use `tsng up` to run the agent yourself" + .into()), } } @@ -4900,7 +5009,14 @@ async fn stop_signal() -> &'static str { _ = terminate.recv() => "terminated", } } - #[cfg(not(unix))] + #[cfg(windows)] + { + tokio::select! { + _ = tokio::signal::ctrl_c() => "interrupted", + _ = winservice::stop_requested() => "stopped by the service manager", + } + } + #[cfg(not(any(unix, windows)))] { let _ = tokio::signal::ctrl_c().await; "interrupted" @@ -5943,3 +6059,93 @@ mod network_context_tests { assert_eq!(standing, NetworkStanding::Stopped); } } + +#[cfg(test)] +mod service_log_tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use std::io::Write; + + use super::*; + + #[test] + fn the_log_appends_and_a_large_one_is_moved_aside() { + let dir = tempfile::tempdir().unwrap(); + let logs = dir.path().join("logs"); + let mut file = open_log_in(&logs).unwrap(); + writeln!(file, "one").unwrap(); + drop(file); + let mut file = open_log_in(&logs).unwrap(); + writeln!(file, "two").unwrap(); + drop(file); + assert_eq!( + std::fs::read_to_string(logs.join("tsng.log")).unwrap(), + "one\ntwo\n" + ); + + let big = std::fs::File::options() + .append(true) + .open(logs.join("tsng.log")) + .unwrap(); + big.set_len(SERVICE_LOG_LIMIT + 1).unwrap(); + drop(big); + let file = open_log_in(&logs).unwrap(); + assert_eq!(file.metadata().unwrap().len(), 0); + assert!(logs.join("tsng.log.old").exists()); + } + + #[test] + fn a_log_that_cannot_be_opened_is_none() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + std::fs::write(&file, b"x").unwrap(); + // A directory cannot be made under a regular file. + assert!(open_log_in(&file.join("logs")).is_none()); + } + + #[test] + fn the_service_keeps_its_state_under_program_data_unless_told_otherwise() { + use clap::Parser; + let parse = |extra: &[&str]| { + let mut words = vec!["tsng", "service"]; + words.extend_from_slice(extra); + match Cli::try_parse_from(words).unwrap().command { + Command::Service(args) => *args, + other => panic!("{other:?}"), + } + }; + let root = std::path::Path::new("/pd"); + let args = service_defaults(parse(&[]), root); + assert_eq!( + args.paths.state_dir, + Some(root.join("Tsunagi").join("data")) + ); + assert_eq!( + args.paths.cache_dir, + Some(root.join("Tsunagi").join("cache")) + ); + assert_eq!( + args.control_socket, + Some(tsunagi::ipc::default_control_socket_path()) + ); + + let args = service_defaults( + parse(&["--state-dir", "/mine", "--control-socket", "/mine/sock"]), + root, + ); + assert_eq!(args.paths.state_dir, Some(PathBuf::from("/mine"))); + assert_eq!(args.control_socket, Some(PathBuf::from("/mine/sock"))); + } + + #[test] + fn the_service_command_is_refused_off_the_service_manager() { + use clap::Parser; + let cli = Cli::try_parse_from(["tsng", "service"]).unwrap(); + assert!(matches!(cli.command, Command::Service(_))); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let err = runtime.block_on(run(cli.command)).unwrap_err(); + assert!(err.to_string().contains("service manager"), "{err}"); + } +} diff --git a/crates/tsunagi/src/winservice.rs b/crates/tsunagi/src/winservice.rs new file mode 100644 index 0000000..c6910ee --- /dev/null +++ b/crates/tsunagi/src/winservice.rs @@ -0,0 +1,121 @@ +//! The agent as a Windows service. +//! +//! `tsng service` is what the installer registers with the service control +//! manager (`sc create`); nobody types it. The manager starts the process, +//! which must call into the dispatcher quickly and then report its state, so +//! this is the only place the Windows service API is touched. Everything the +//! agent does is the same `up` that runs in a console; the only differences +//! are that it stops when the manager says so rather than on Ctrl-C, and that +//! it logs to a file because a service has no console. +//! +//! The one `unsafe` here is not ours: `define_windows_service!` expands to the +//! `extern "system"` entry point the manager calls, which has to be unsafe. + +#![allow(unsafe_code)] + +use std::ffi::OsString; +use std::sync::OnceLock; +use std::time::Duration; + +use windows_service::service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType, +}; +use windows_service::service_control_handler::{ + self, ServiceControlHandlerResult, ServiceStatusHandle, +}; +use windows_service::{define_windows_service, service_dispatcher}; + +/// The name the installer registers the service under. +pub const SERVICE_NAME: &str = "Tsunagi"; + +/// How long the manager waits for a stop before it gives up on us. The agent's +/// own shutdown is bounded and shorter. +const STOP_WAIT: Duration = Duration::from_secs(25); + +/// Set when the manager asks the service to stop. +static STOP: tokio::sync::Notify = tokio::sync::Notify::const_new(); + +/// What `up` does, handed over by `main` because the manager's entry point +/// cannot take arguments. +type Body = Box Result<(), String> + Send + Sync>; +static BODY: OnceLock = OnceLock::new(); + +/// Where the manager's status handle is kept, so the stop handler can report. +static STATUS: OnceLock = OnceLock::new(); + +/// Resolves when the manager has asked the service to stop. +pub async fn stop_requested() { + STOP.notified().await; +} + +define_windows_service!(ffi_service_main, service_main); + +/// Hands the process to the service control manager and returns when the +/// service has stopped. Fails at once when not started by the manager, which +/// is how running `tsng service` in a console is reported. +pub fn run(body: Body) -> Result<(), windows_service::Error> { + let _ = BODY.set(body); + service_dispatcher::start(SERVICE_NAME, ffi_service_main) +} + +fn service_main(_arguments: Vec) { + let result = serve(); + let code = match &result { + Ok(()) => ServiceExitCode::Win32(0), + Err(err) => { + tracing::error!("the service failed: {err}"); + // A non-zero exit lets the recovery actions restart it. + ServiceExitCode::ServiceSpecific(1) + } + }; + if let Some(handle) = STATUS.get() { + let _ = handle.set_service_status(status(ServiceState::Stopped, code, Duration::ZERO)); + } +} + +fn serve() -> Result<(), String> { + let handle = service_control_handler::register(SERVICE_NAME, |control| match control { + ServiceControl::Stop | ServiceControl::Shutdown => { + if let Some(handle) = STATUS.get() { + let _ = handle.set_service_status(status( + ServiceState::StopPending, + ServiceExitCode::Win32(0), + STOP_WAIT, + )); + } + STOP.notify_one(); + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + }) + .map_err(|err| format!("cannot register with the service control manager: {err}"))?; + let _ = STATUS.set(handle); + handle + .set_service_status(status( + ServiceState::Running, + ServiceExitCode::Win32(0), + Duration::ZERO, + )) + .map_err(|err| format!("cannot report the service as running: {err}"))?; + match BODY.get() { + Some(body) => body(), + None => Err("the service has nothing to run".to_string()), + } +} + +fn status(state: ServiceState, exit_code: ServiceExitCode, wait_hint: Duration) -> ServiceStatus { + ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: if state == ServiceState::Running { + ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN + } else { + ServiceControlAccept::empty() + }, + exit_code, + checkpoint: 0, + wait_hint, + process_id: None, + } +} diff --git a/dist/windows/README.md b/dist/windows/README.md index 1cdef45..aceec40 100644 --- a/dist/windows/README.md +++ b/dist/windows/README.md @@ -1,5 +1,34 @@ # Running tsunagi on Windows +## The installer (recommended) + +`tsunagi-setup--x86_64.exe` does everything in one go. Run it as an +administrator (it asks): + +- installs `tsng.exe`, the tray app and **Wintun** to `C:\Program Files\Tsunagi`; +- registers and starts the **Tsunagi** Windows service (it starts at boot and + restarts after a crash), running as the system account; +- adds `tsng` to the PATH and an inbound Windows Firewall rule for the agent + (both are tasks you can untick); +- starts the tray icon at sign-in. + +Where things live: the device's identity and networks in +`%ProgramData%\Tsunagi\data` (readable only by administrators and the system), +the log in `%ProgramData%\Tsunagi\logs\tsng.log`. The service keeps its own +state: networks joined by an agent you started by hand (`tsng up`, which keeps +its data in your user profile) are not carried over; join them again with +`tsng join`. The installer stops such an agent, because it cannot run beside the +service. + +`tsng status`, `tsng join` and the tray work from an ordinary, non-elevated +terminal. Manage the service with the Services app or `sc stop Tsunagi` / +`sc start Tsunagi`. Silent install: `/VERYSILENT /SUPPRESSMSGBOXES`; choose +tasks with `/TASKS="!autostart,addtopath,firewall"`. Uninstalling removes the +service, the firewall rule and the PATH entry, and asks before deleting the +data. + +## By hand (the zip) + This archive holds `tsng.exe`. The overlay interface is a Wintun adapter, so the agent needs two things to carry real traffic: diff --git a/dist/windows/tsunagi.iss b/dist/windows/tsunagi.iss new file mode 100644 index 0000000..83954af --- /dev/null +++ b/dist/windows/tsunagi.iss @@ -0,0 +1,241 @@ +; Inno Setup script for the Tsunagi Windows installer. +; +; One installer that leaves a working machine: +; * the agent (tsng.exe), as a Windows service that starts at boot +; * Wintun, the driver library behind the overlay interface +; * the tray application, started at sign-in +; * `tsng` on the PATH, and an inbound Defender Firewall rule for the agent +; +; Built in CI (see .github/workflows/release.yml); locally: +; ISCC.exe /DAppVersion=0.1.0 /DSourceDir= +; /DWintunDir= /O dist\windows\tsunagi.iss +; Silent install: tsunagi-setup--x86_64.exe /VERYSILENT /SUPPRESSMSGBOXES +; Pick tasks with /TASKS="!autostart,addtopath,firewall". + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif +#ifndef SourceDir + #error SourceDir must point at the directory holding tsng.exe and tsunagi-tray.exe +#endif +#ifndef WintunDir + #error WintunDir must point at the directory holding wintun.dll +#endif + +#define ServiceName "Tsunagi" + +[Setup] +AppId={{F635E5DD-250D-499F-B0B3-AFC7CC64A15D} +AppName=Tsunagi +AppVersion={#AppVersion} +AppPublisher=Tsunagi +DefaultDirName={autopf}\Tsunagi +DefaultGroupName=Tsunagi +DisableProgramGroupPage=yes +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputBaseFilename=tsunagi-setup-{#AppVersion}-x86_64 +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ChangesEnvironment=yes +UninstallDisplayName=Tsunagi +; The service and the tray are stopped by hand below, so the installer does +; not need to ask Windows to close applications. +CloseApplications=no + +[Tasks] +Name: "autostart"; Description: "Start the tray icon when I sign in"; GroupDescription: "Tsunagi:" +Name: "addtopath"; Description: "Add tsng to the PATH"; GroupDescription: "Tsunagi:" +Name: "firewall"; Description: "Allow the agent through Windows Firewall (direct connections between devices)"; GroupDescription: "Tsunagi:" + +[Files] +Source: "{#SourceDir}\tsng.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#SourceDir}\tsunagi-tray.exe"; DestDir: "{app}"; Flags: ignoreversion +; Wintun is the unmodified, signed library from wintun.net; its licence allows +; shipping it with an application and asks for the licence text beside it. +Source: "{#WintunDir}\wintun.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#WintunDir}\LICENSE.txt"; DestDir: "{app}\licenses"; DestName: "wintun-LICENSE.txt"; Flags: ignoreversion + +[Dirs] +; Identity and network secrets live here; locked down below. +Name: "{commonappdata}\Tsunagi\data" +Name: "{commonappdata}\Tsunagi\cache" +Name: "{commonappdata}\Tsunagi\logs" + +[Icons] +Name: "{group}\Tsunagi"; Filename: "{app}\tsunagi-tray.exe" +Name: "{commonstartup}\Tsunagi"; Filename: "{app}\tsunagi-tray.exe"; Tasks: autostart + +[Registry] +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ + ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ + Check: NeedsAddPath('{app}'); Tasks: addtopath + +[Run] +; Only the system account and administrators may read the state: it holds the +; device's key and every network's secret. SIDs, so it works in any language. +Filename: "{sys}\icacls.exe"; \ + Parameters: """{commonappdata}\Tsunagi\data"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \ + Flags: runhidden; StatusMsg: "Protecting the agent's data..." +Filename: "{sys}\icacls.exe"; \ + Parameters: """{commonappdata}\Tsunagi\cache"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \ + Flags: runhidden +Filename: "{sys}\icacls.exe"; \ + Parameters: """{commonappdata}\Tsunagi\logs"" /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F"; \ + Flags: runhidden +; The tray runs as the person who installed, not elevated, like it does at sign-in. +Filename: "{app}\tsunagi-tray.exe"; Description: "Start the Tsunagi tray"; \ + Flags: nowait postinstall skipifsilent runasoriginaluser + +[UninstallRun] +Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""Tsunagi"""; \ + Flags: runhidden; RunOnceId: "RemoveFirewallRule" + +[Code] +const + ServiceName = '{#ServiceName}'; + EnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + +function Sc(const Params: String): Integer; +var + Code: Integer; +begin + if Exec(ExpandConstant('{sys}\sc.exe'), Params, '', SW_HIDE, ewWaitUntilTerminated, Code) then + Result := Code + else + Result := -1; +end; + +function ServiceExists: Boolean; +begin + // 1060 is ERROR_SERVICE_DOES_NOT_EXIST. + Result := Sc('query ' + ServiceName) <> 1060; +end; + +procedure StopService; +var + Code: Integer; +begin + // `net stop` waits until the service has stopped, or says it was not running. + Exec(ExpandConstant('{sys}\net.exe'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, Code); +end; + +procedure StopTray; +var + Code: Integer; +begin + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM tsunagi-tray.exe', '', SW_HIDE, ewWaitUntilTerminated, Code); +end; + +// An agent started by hand (`tsng up`) holds the control pipe and the Wintun +// adapter, and the file about to be replaced: it cannot run beside the service. +procedure StopStrayAgents; +var + Code: Integer; +begin + Exec(ExpandConstant('{sys}\taskkill.exe'), '/F /IM tsng.exe', '', SW_HIDE, ewWaitUntilTerminated, Code); +end; + +function NeedsAddPath(Param: String): Boolean; +var + Current: String; +begin + if not RegQueryStringValue(HKLM, EnvKey, 'Path', Current) then + begin + Result := True; + exit; + end; + Result := Pos(';' + Uppercase(ExpandConstant(Param)) + ';', ';' + Uppercase(Current) + ';') = 0; +end; + +procedure RemoveFromPath; +var + Current, Entry: String; + At: Integer; +begin + if not RegQueryStringValue(HKLM, EnvKey, 'Path', Current) then + exit; + Entry := ';' + ExpandConstant('{app}'); + At := Pos(Uppercase(Entry), Uppercase(Current)); + if At > 0 then + begin + Delete(Current, At, Length(Entry)); + RegWriteExpandStringValue(HKLM, EnvKey, 'Path', Current); + end; +end; + +// Stops what is about to be replaced: the files are in use while it runs. +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + StopTray; + if ServiceExists then + StopService; + StopStrayAgents; + Result := ''; +end; + +procedure InstallService; +var + App: String; +begin + App := ExpandConstant('{app}\tsng.exe'); + // The command line is `tsng service`: it keeps its state under + // %ProgramData%\Tsunagi and serves the system-wide control socket. + if ServiceExists then + Sc('config ' + ServiceName + ' binPath= "\"' + App + '\" service" start= auto obj= LocalSystem') + else + Sc('create ' + ServiceName + ' binPath= "\"' + App + '\" service" start= auto obj= LocalSystem DisplayName= "Tsunagi agent"'); + Sc('description ' + ServiceName + ' "Tsunagi: private mesh network agent"'); + // Restart after a crash, backing off, and forget the failures after a day. + Sc('failure ' + ServiceName + ' reset= 86400 actions= restart/5000/restart/5000/restart/30000'); + Sc('start ' + ServiceName); +end; + +procedure InstallFirewallRule; +var + Code: Integer; + Netsh: String; +begin + Netsh := ExpandConstant('{sys}\netsh.exe'); + Exec(Netsh, 'advfirewall firewall delete rule name="Tsunagi"', '', SW_HIDE, ewWaitUntilTerminated, Code); + if WizardIsTaskSelected('firewall') then + Exec(Netsh, + 'advfirewall firewall add rule name="Tsunagi" dir=in action=allow enable=yes profile=any program="' + + ExpandConstant('{app}\tsng.exe') + '"', + '', SW_HIDE, ewWaitUntilTerminated, Code); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + InstallFirewallRule; + InstallService; + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + StopTray; + if ServiceExists then + begin + StopService; + Sc('delete ' + ServiceName); + end; + StopStrayAgents; + RemoveFromPath; + end; + if CurUninstallStep = usPostUninstall then + begin + // The data is the device's identity and its networks: kept unless asked, + // and never removed by a silent uninstall. + if (not UninstallSilent) and + (MsgBox('Also delete this device''s identity, networks and logs?' + #13#10 + + 'Choose No to keep them for a later reinstall.', mbConfirmation, MB_YESNO or MB_DEFBUTTON2) = IDYES) then + DelTree(ExpandConstant('{commonappdata}\Tsunagi'), True, True, True); + end; +end;