Windows installer: agent service, Wintun, tray and PATH in one setup program
Running on Windows needed wintun.dll by hand, an elevated console and a
manually started agent; without the DLL the agent silently ran with no
interface (and so no exit node or routing at all).
`tsng service` (hidden) runs the same `up` under the Windows service manager:
src/winservice.rs registers with the SCM through the windows-service crate,
reports Running/StopPending/Stopped, and maps a stop request onto the existing
shutdown path (stop_signal). The only unsafe is the extern "system" entry point
that define_windows_service! expands to, allowed in that module alone. A
service has no console, so it logs to %ProgramData%\Tsunagi\logs\tsng.log
(one older file kept past 8 MiB). Its state defaults to
%ProgramData%\Tsunagi\{data,cache} and its control socket to the system-wide
default every client already uses, so an unelevated `tsng status` and the tray
find it.
dist/windows/tsunagi.iss (Inno Setup) installs tsng.exe, the tray and Wintun
0.14.1 (pinned by SHA-256 in CI, licence text shipped), registers the
"Tsunagi" service as LocalSystem with auto start and restart-on-failure, locks
the data/cache/log directories to SYSTEM and Administrators by SID, and by
tasks adds tsng to the PATH, an inbound firewall rule for the agent and a tray
autostart. It stops the service, the tray and any hand-started agent before
replacing files; uninstall removes the service, rule and PATH entry and asks
before deleting the data (never on a silent uninstall). The release workflows
(GitHub and Gitea) build it on the Windows job and publish
tsunagi-setup-<version>-x86_64.exe.
.cargo/config.toml links the MSVC C runtime statically so nothing needs a
Visual C++ redistributable on a clean machine.
Compatibility: an agent run by hand keeps its data in the user profile; the
service has its own, so networks must be joined again once.
Checks: cargo fmt --check, clippy -D warnings, cargo test --workspace passed on
macOS (one unrelated wireguard restart test failed once under load and passed
on three reruns). winservice.rs was cargo-checked for x86_64-pc-windows-msvc in
a scratch crate; the whole tsunagi crate cannot be built for Windows from
macOS (aws-lc needs the Windows SDK). The .iss script and the CI step were NOT
compiled or run: no Inno Setup or Windows here.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c176e0b74
commit
21328bf818
9 files changed
+672
-7
No files matched your search
@@ -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"]
|
||||||
@@ -190,6 +190,29 @@ jobs:
|
|||||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||||
fi
|
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
|
# 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
|
# the static binary, the systemd service and the polkit rule. Built with
|
||||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||||
@@ -251,6 +274,7 @@ jobs:
|
|||||||
${{ matrix.asset }}-*.*
|
${{ matrix.asset }}-*.*
|
||||||
*.deb
|
*.deb
|
||||||
*.pkg.tar.zst
|
*.pkg.tar.zst
|
||||||
|
tsunagi-setup-*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
@@ -322,5 +346,5 @@ jobs:
|
|||||||
cat up.json >&2 || true
|
cat up.json >&2 || true
|
||||||
rc=1
|
rc=1
|
||||||
fi
|
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"
|
exit "$rc"
|
||||||
@@ -192,6 +192,29 @@ jobs:
|
|||||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||||
fi
|
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
|
# 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
|
# the static binary, the systemd service and the polkit rule. Built with
|
||||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||||
@@ -250,6 +273,7 @@ jobs:
|
|||||||
${{ matrix.asset }}-*.*
|
${{ matrix.asset }}-*.*
|
||||||
*.deb
|
*.deb
|
||||||
*.pkg.tar.zst
|
*.pkg.tar.zst
|
||||||
|
tsunagi-setup-*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
@@ -278,7 +302,7 @@ jobs:
|
|||||||
|
|
||||||
# download-artifact nests each artifact under its own directory, so
|
# download-artifact nests each artifact under its own directory, so
|
||||||
# the archives are artifacts/<asset>/<file>.
|
# the archives are artifacts/<asset>/<file>.
|
||||||
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[@]}"
|
printf 'asset: %s\n' "${files[@]}"
|
||||||
|
|
||||||
prerelease=()
|
prerelease=()
|
||||||
|
|||||||
Generated
+12
@@ -6250,6 +6250,7 @@ dependencies = [
|
|||||||
"tsunagi-tcp-tls",
|
"tsunagi-tcp-tls",
|
||||||
"tsunagi-wg-quic",
|
"tsunagi-wg-quic",
|
||||||
"tsunagi-wireguard",
|
"tsunagi-wireguard",
|
||||||
|
"windows-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7135,6 +7136,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ netwatch = "0.19.3"
|
|||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
nix = { version = "0.31", features = ["user"] }
|
nix = { version = "0.31", features = ["user"] }
|
||||||
|
|
||||||
|
[target."cfg(windows)".dependencies]
|
||||||
|
windows-service = "0.8"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+211
-5
@@ -11,6 +11,9 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod winservice;
|
||||||
|
|
||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use tsunagi::agent::Event;
|
use tsunagi::agent::Event;
|
||||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||||
@@ -84,6 +87,10 @@ enum Command {
|
|||||||
ExitNode(ExitNodeArgs),
|
ExitNode(ExitNodeArgs),
|
||||||
/// Removes everything this device has stored and starts over.
|
/// Removes everything this device has stored and starts over.
|
||||||
Wipe(WipeArgs),
|
Wipe(WipeArgs),
|
||||||
|
/// Runs the agent under the Windows service manager. Registered by the
|
||||||
|
/// installer, not typed.
|
||||||
|
#[command(hide = true)]
|
||||||
|
Service(Box<UpArgs>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
@@ -757,12 +764,26 @@ fn main() -> std::process::ExitCode {
|
|||||||
|
|
||||||
let is_debug = log_filter.contains("debug") || log_filter.contains("trace");
|
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_env_filter(tracing_subscriber::EnvFilter::new(&log_filter))
|
||||||
.with_target(is_debug)
|
.with_target(is_debug)
|
||||||
.compact()
|
.compact();
|
||||||
.with_writer(std::io::stderr)
|
// A service has no console: it writes to a file.
|
||||||
.init();
|
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.
|
// The library never starts a runtime; this binary owns it.
|
||||||
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
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<std::fs::File> {
|
||||||
|
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::File> {
|
||||||
|
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<dyn std::error::Error>> {
|
async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
Command::Id(args) => id(args).await,
|
Command::Id(args) => id(args).await,
|
||||||
@@ -796,6 +900,11 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Command::Dns(args) => dns_command(args).await,
|
Command::Dns(args) => dns_command(args).await,
|
||||||
Command::ExitNode(args) => exit_node_command(args).await,
|
Command::ExitNode(args) => exit_node_command(args).await,
|
||||||
Command::Wipe(args) => wipe(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",
|
_ = 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;
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
"interrupted"
|
"interrupted"
|
||||||
@@ -5943,3 +6059,93 @@ mod network_context_tests {
|
|||||||
assert_eq!(standing, NetworkStanding::Stopped);
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn Fn() -> Result<(), String> + Send + Sync>;
|
||||||
|
static BODY: OnceLock<Body> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Where the manager's status handle is kept, so the stop handler can report.
|
||||||
|
static STATUS: OnceLock<ServiceStatusHandle> = 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<OsString>) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+29
@@ -1,5 +1,34 @@
|
|||||||
# Running tsunagi on Windows
|
# Running tsunagi on Windows
|
||||||
|
|
||||||
|
## The installer (recommended)
|
||||||
|
|
||||||
|
`tsunagi-setup-<version>-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
|
This archive holds `tsng.exe`. The overlay interface is a Wintun adapter, so
|
||||||
the agent needs two things to carry real traffic:
|
the agent needs two things to carry real traffic:
|
||||||
|
|
||||||
|
|||||||
Vendored
+241
@@ -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=<dir with tsng.exe and tsunagi-tray.exe>
|
||||||
|
; /DWintunDir=<wintun\bin\amd64> /O<output dir> dist\windows\tsunagi.iss
|
||||||
|
; Silent install: tsunagi-setup-<version>-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;
|
||||||
Reference in new issue
Block a user