From d8aaac4965a689c912292d906adefc17da5374b0 Mon Sep 17 00:00:00 2001 From: "lucretia.dietz" Date: Mon, 20 Jul 2026 15:01:15 +0200 Subject: [PATCH] feat(client): adds windows installer and InsecureSkipTlsVerify flag --- deploy/install-client.ps1 | 174 ++++++++++++++++++ src/Ngino.Client/ClientOptions.cs | 18 +- src/Ngino.Client/Program.cs | 4 + src/Ngino.Client/TunnelClient.cs | 5 + .../Ngino.Client.Tests/ClientOptionsTests.cs | 31 ++++ .../PackageAuditTests.cs | 0 .../ReverseLlama.Client.Tests.csproj | 0 .../TunnelClientModelTests.cs | 0 .../UpstreamRequestTests.cs | 0 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 deploy/install-client.ps1 create mode 100644 tests/Ngino.Client.Tests/ClientOptionsTests.cs rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/PackageAuditTests.cs (100%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/ReverseLlama.Client.Tests.csproj (100%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/TunnelClientModelTests.cs (100%) rename tests/{ReverseLlama.Client.Tests => Ngino.Client.Tests}/UpstreamRequestTests.cs (100%) diff --git a/deploy/install-client.ps1 b/deploy/install-client.ps1 new file mode 100644 index 0000000..92082af --- /dev/null +++ b/deploy/install-client.ps1 @@ -0,0 +1,174 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$Server, + + [Parameter(Mandatory = $false)] + [string]$Token, + + [string]$ClientId = $(if ($env:COMPUTERNAME) { $env:COMPUTERNAME.ToLowerInvariant() } else { "windows-client" }), + [string]$Upstream = "http://localhost:11434", + [string]$InstallDir = "$env:ProgramFiles\Ngino Client", + [string]$ServiceName = "NginoClient", + [switch]$InsecureSkipTlsVerify, + [switch]$NoOllama +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Info([string]$Message) { Write-Host "[INFO] $Message" -ForegroundColor Green } +function Write-Warn([string]$Message) { Write-Host "[WARN] $Message" -ForegroundColor Yellow } + +function Find-DotNet { + $command = Get-Command dotnet -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + + $candidate = Join-Path $env:ProgramFiles "dotnet\dotnet.exe" + if (Test-Path -LiteralPath $candidate) { return $candidate } + return $null +} + +function Install-WingetPackage([string]$Id, [string]$Name) { + if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { + throw "$Name is required but winget is unavailable. Install $Name manually and run this script again." + } + + Write-Info "Installing $Name..." + & winget.exe install --id $Id --exact --accept-package-agreements --accept-source-agreements --silent + if ($LASTEXITCODE -ne 0) { throw "winget failed to install $Name (exit code $LASTEXITCODE)." } +} + +if ([string]::IsNullOrWhiteSpace($Server)) { + $Server = Read-Host "Ngino server URL (e.g. http://my-server:5050)" +} +if ([string]::IsNullOrWhiteSpace($Server)) { throw "Server URL is required." } + +if ([string]::IsNullOrWhiteSpace($Token)) { + $secureToken = Read-Host "Server token" -AsSecureString + $tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) + try { $Token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) } +} +if ([string]::IsNullOrWhiteSpace($Token)) { throw "Token is required." } +if ($ServiceName -notmatch '^[A-Za-z0-9_.-]+$') { throw "ServiceName contains unsupported characters." } + +$scriptDir = $PSScriptRoot +$repoRoot = Split-Path -Parent $scriptDir +$clientProject = Join-Path $repoRoot "src\Ngino.Client\Ngino.Client.csproj" +if (-not (Test-Path -LiteralPath $clientProject)) { + throw "Client source not found at $clientProject. Run this script from the repository." +} + +$dotnet = Find-DotNet +$dotnetVersion = if ($dotnet) { & $dotnet --version } else { $null } +if (-not $dotnetVersion -or -not $dotnetVersion.StartsWith("10.")) { + if ($dotnetVersion) { Write-Warn "dotnet $dotnetVersion is installed, but version 10.x is required." } + Install-WingetPackage "Microsoft.DotNet.SDK.10" ".NET 10 SDK" + $dotnet = Find-DotNet + if (-not $dotnet) { throw ".NET was installed, but dotnet.exe could not be found." } + $dotnetVersion = & $dotnet --version +} +Write-Info "Using dotnet $dotnetVersion ($dotnet)." + +if ($NoOllama) { + Write-Info "Skipping Ollama check (-NoOllama)." +} else { + $ollama = Get-Command ollama.exe -ErrorAction SilentlyContinue + if (-not $ollama) { + $ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe" + if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate } + } + if (-not $ollama) { + Install-WingetPackage "Ollama.Ollama" "Ollama" + $ollamaCandidate = Join-Path $env:LOCALAPPDATA "Programs\Ollama\ollama.exe" + if (Test-Path -LiteralPath $ollamaCandidate) { $ollama = Get-Item $ollamaCandidate } + } + if (-not $ollama) { Write-Warn "Ollama was installed, but ollama.exe was not found in the current session." } + else { + $ollamaPath = if ($ollama.Source) { $ollama.Source } elseif ($ollama.FullName) { $ollama.FullName } else { $ollama.Path } + Write-Info "Ollama is installed ($ollamaPath)." + } +} + +$architecture = $env:PROCESSOR_ARCHITECTURE +$runtimeId = switch ($architecture) { + { $_ -in "AMD64", "x64" } { "win-x64"; break } + { $_ -in "ARM64", "Arm64" } { "win-arm64"; break } + { $_ -in "x86", "X86" } { "win-x86"; break } + default { throw "Unsupported architecture: $architecture" } +} + +$buildDir = Join-Path ([IO.Path]::GetTempPath()) ("ngino-build-" + [Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $buildDir | Out-Null +try { + Write-Info "Building Ngino client (self-contained, $runtimeId)..." + & $dotnet publish $clientProject -c Release -r $runtimeId --self-contained true -o $buildDir + if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed (exit code $LASTEXITCODE)." } + + $executable = Join-Path $buildDir "Ngino.Client.exe" + if (-not (Test-Path -LiteralPath $executable)) { throw "Build failed: Ngino.Client.exe was not produced." } + + $existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + if ($existingService -and $existingService.Status -ne "Stopped") { + Write-Info "Stopping existing service $ServiceName..." + Stop-Service -Name $ServiceName -Force + (Get-Service -Name $ServiceName).WaitForStatus("Stopped", [TimeSpan]::FromSeconds(30)) + } + + Write-Info "Installing to $InstallDir..." + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Copy-Item -Path (Join-Path $buildDir "*") -Destination $InstallDir -Recurse -Force +} finally { + if (Test-Path -LiteralPath $buildDir) { Remove-Item -LiteralPath $buildDir -Recurse -Force } +} + +$installedExecutable = Join-Path $InstallDir "Ngino.Client.exe" +$binaryPath = '"{0}"' -f $installedExecutable +if (-not (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue)) { + Write-Info "Creating Windows service $ServiceName..." + & sc.exe create $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client" + if ($LASTEXITCODE -ne 0) { throw "Could not create Windows service $ServiceName." } +} else { + & sc.exe config $ServiceName "binPath=" $binaryPath "start=" "auto" "DisplayName=" "Ngino Tunnel Client" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Could not update Windows service $ServiceName." } +} + +# A service-specific environment keeps the token out of the process command line. +$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$ServiceName" +$insecureTlsValue = $InsecureSkipTlsVerify.IsPresent.ToString().ToLowerInvariant() +$serviceEnvironment = @( + "NGINO_SERVER=$Server", + "NGINO_TOKEN=$Token", + "NGINO_CLIENT_ID=$ClientId", + "NGINO_UPSTREAM=$Upstream", + "NGINO_INSECURE_SKIP_TLS_VERIFY=$insecureTlsValue", + "DOTNET_CLI_TELEMETRY_OPTOUT=1", + "DOTNET_NOLOGO=1" +) +if ($InsecureSkipTlsVerify) { + Write-Warn "Server TLS certificate validation is disabled for $ServiceName." +} +New-ItemProperty -Path $serviceRegistryPath -Name Environment -PropertyType MultiString -Value $serviceEnvironment -Force | Out-Null +& sc.exe description $ServiceName "Ngino outbound tunnel client" | Out-Null +& sc.exe failure $ServiceName "reset=" "86400" "actions=" "restart/5000/restart/5000/restart/5000" | Out-Null + +Start-Service -Name $ServiceName +$service = Get-Service -Name $ServiceName +try { $service.WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) } catch { } +if ($service.Status -eq "Running") { Write-Info "Service $ServiceName is running." } +else { Write-Warn "Service $ServiceName did not reach Running state. Check: Get-WinEvent -LogName Application" } + +Write-Host "" +Write-Info "Installation complete." +Write-Host " Server: $Server" +Write-Host " Client ID: $ClientId" +Write-Host " Upstream: $Upstream" +Write-Host " Service: $ServiceName" +Write-Host " Install dir: $InstallDir" +Write-Host "" +Write-Host " Manage: Get-Service $ServiceName | Start-Service/Stop-Service/Restart-Service" +Write-Host " Logs: Get-WinEvent -LogName Application | Where-Object ProviderName -eq NginoClient" diff --git a/src/Ngino.Client/ClientOptions.cs b/src/Ngino.Client/ClientOptions.cs index 3d4c4f3..f50fd63 100644 --- a/src/Ngino.Client/ClientOptions.cs +++ b/src/Ngino.Client/ClientOptions.cs @@ -18,6 +18,8 @@ internal sealed class ClientOptions public int ChunkSize { get; init; } = 64 * 1024; + public bool InsecureSkipTlsVerify { get; init; } + public Uri TunnelUri { get @@ -53,7 +55,8 @@ internal sealed class ClientOptions Token = Read(values, "token", "NGINO_TOKEN"), ClientId = Read(values, "client-id", "NGINO_CLIENT_ID") ?? Environment.MachineName.ToLowerInvariant(), ReconnectDelay = TimeSpan.FromSeconds(ReadInt(values, 5, "reconnect-delay", "NGINO_RECONNECT_DELAY_SECONDS")), - ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "NGINO_CHUNK_SIZE") + ChunkSize = ReadInt(values, 64 * 1024, "chunk-size", "NGINO_CHUNK_SIZE"), + InsecureSkipTlsVerify = ReadBool(values, false, "insecure-skip-tls-verify", "NGINO_INSECURE_SKIP_TLS_VERIFY") }; } @@ -67,6 +70,7 @@ internal sealed class ClientOptions --tunnel-path Defaults to /_ngino/tunnel --reconnect-delay Defaults to 5 --chunk-size Defaults to 65536 + --insecure-skip-tls-verify Disable server TLS certificate validation (unsafe) """; private static Dictionary ParseArgs(string[] args) @@ -88,6 +92,12 @@ internal sealed class ClientOptions continue; } + if (keyValue[0].Equals("insecure-skip-tls-verify", StringComparison.OrdinalIgnoreCase)) + { + values[keyValue[0]] = "true"; + continue; + } + if (i + 1 >= args.Length || args[i + 1].StartsWith("--", StringComparison.Ordinal)) { throw new ArgumentException($"Missing value for '{arg}'."); @@ -124,6 +134,12 @@ internal sealed class ClientOptions return int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback; } + private static bool ReadBool(Dictionary values, bool fallback, params string[] keys) + { + var value = Read(values, keys); + return bool.TryParse(value, out var parsed) ? parsed : fallback; + } + private static Uri ReadUri(Dictionary values, string key, string envKey, string fallback) { var value = Read(values, key, envKey) ?? fallback; diff --git a/src/Ngino.Client/Program.cs b/src/Ngino.Client/Program.cs index 4d5a1e4..45668a4 100644 --- a/src/Ngino.Client/Program.cs +++ b/src/Ngino.Client/Program.cs @@ -11,6 +11,10 @@ try Console.WriteLine($" client id: {options.ClientId}"); Console.WriteLine($" server tunnel: {options.TunnelUri}"); Console.WriteLine($" local upstream: {options.Upstream}"); + if (options.InsecureSkipTlsVerify) + { + Console.WriteLine(" WARNING: server TLS certificate validation is disabled"); + } // Args are parsed by ClientOptions; keep them away from the host configuration. var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings { Args = [] }); diff --git a/src/Ngino.Client/TunnelClient.cs b/src/Ngino.Client/TunnelClient.cs index f61e38c..385ec53 100644 --- a/src/Ngino.Client/TunnelClient.cs +++ b/src/Ngino.Client/TunnelClient.cs @@ -41,6 +41,11 @@ internal sealed class TunnelClient using var socket = new ClientWebSocket(); socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); + if (_options.InsecureSkipTlsVerify) + { + socket.Options.RemoteCertificateValidationCallback = static (_, _, _, _) => true; + } + if (!string.IsNullOrWhiteSpace(_options.Token)) { socket.Options.SetRequestHeader(ProtocolConstants.TokenHeader, _options.Token); diff --git a/tests/Ngino.Client.Tests/ClientOptionsTests.cs b/tests/Ngino.Client.Tests/ClientOptionsTests.cs new file mode 100644 index 0000000..793d427 --- /dev/null +++ b/tests/Ngino.Client.Tests/ClientOptionsTests.cs @@ -0,0 +1,31 @@ +using Ngino.Client; +using Xunit; + +namespace Ngino.Client.Tests; + +public sealed class ClientOptionsTests +{ + [Fact] + public void InsecureTlsIsDisabledByDefault() + { + var options = ClientOptions.Parse([]); + + Assert.False(options.InsecureSkipTlsVerify); + } + + [Fact] + public void InsecureTlsCanBeEnabledWithFlag() + { + var options = ClientOptions.Parse(["--insecure-skip-tls-verify"]); + + Assert.True(options.InsecureSkipTlsVerify); + } + + [Fact] + public void InsecureTlsCanBeEnabledWithEnvironmentStyleValue() + { + var options = ClientOptions.Parse(["--insecure-skip-tls-verify=true"]); + + Assert.True(options.InsecureSkipTlsVerify); + } +} diff --git a/tests/ReverseLlama.Client.Tests/PackageAuditTests.cs b/tests/Ngino.Client.Tests/PackageAuditTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/PackageAuditTests.cs rename to tests/Ngino.Client.Tests/PackageAuditTests.cs diff --git a/tests/ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj b/tests/Ngino.Client.Tests/ReverseLlama.Client.Tests.csproj similarity index 100% rename from tests/ReverseLlama.Client.Tests/ReverseLlama.Client.Tests.csproj rename to tests/Ngino.Client.Tests/ReverseLlama.Client.Tests.csproj diff --git a/tests/ReverseLlama.Client.Tests/TunnelClientModelTests.cs b/tests/Ngino.Client.Tests/TunnelClientModelTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/TunnelClientModelTests.cs rename to tests/Ngino.Client.Tests/TunnelClientModelTests.cs diff --git a/tests/ReverseLlama.Client.Tests/UpstreamRequestTests.cs b/tests/Ngino.Client.Tests/UpstreamRequestTests.cs similarity index 100% rename from tests/ReverseLlama.Client.Tests/UpstreamRequestTests.cs rename to tests/Ngino.Client.Tests/UpstreamRequestTests.cs