Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c136f86cb | ||
|
|
9d01eb5311 | ||
|
|
f6091747d9 | ||
|
|
1cfe53dbe2 |
@@ -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"
|
||||
@@ -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 <path> Defaults to /_ngino/tunnel
|
||||
--reconnect-delay <sec> Defaults to 5
|
||||
--chunk-size <bytes> Defaults to 65536
|
||||
--insecure-skip-tls-verify Disable server TLS certificate validation (unsafe)
|
||||
""";
|
||||
|
||||
private static Dictionary<string, string> 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<string, string> 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<string, string> values, string key, string envKey, string fallback)
|
||||
{
|
||||
var value = Read(values, key, envKey) ?? fallback;
|
||||
|
||||
@@ -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 = [] });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -167,11 +167,14 @@ internal static class AdminEndpoints
|
||||
try
|
||||
{
|
||||
var manual = string.Equals(request.Mode, "manual", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
TimeSpan? duration = manual
|
||||
? null
|
||||
: TimeSpan.FromMinutes(Math.Clamp(request.DurationMinutes ?? 60, 1, 24 * 60));
|
||||
: request.DurationMinutes is { } minutes
|
||||
? TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 24 * 60))
|
||||
: null;
|
||||
|
||||
store.DisableClient(clientId, duration, manual, request.Reason);
|
||||
store.DisableClient(clientId, duration, manual, request.Reason, request.StartAtUtc, request.UntilUtc);
|
||||
return Results.Ok(new { clientId, disabled = true });
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -673,6 +676,7 @@ internal static class AdminEndpoints
|
||||
snapshot?.ActiveModels ?? [],
|
||||
snapshot?.ModelsUpdatedAt,
|
||||
access.IsDisabled,
|
||||
access.DisabledFromUtc,
|
||||
access.DisabledUntilUtc,
|
||||
access.DisabledManually,
|
||||
access.DisabledReason,
|
||||
@@ -880,7 +884,9 @@ internal static class AdminEndpoints
|
||||
internal sealed record DisableClientRequest(
|
||||
string? Mode,
|
||||
int? DurationMinutes,
|
||||
string? Reason);
|
||||
string? Reason,
|
||||
DateTimeOffset? StartAtUtc,
|
||||
DateTimeOffset? UntilUtc);
|
||||
|
||||
internal sealed record ModelActionRequest(
|
||||
string ClientId,
|
||||
@@ -929,6 +935,7 @@ internal sealed record ClientSummary(
|
||||
IReadOnlyList<string> ActiveModels,
|
||||
DateTimeOffset? ModelsUpdatedAt,
|
||||
bool Disabled,
|
||||
DateTimeOffset? DisabledFromUtc,
|
||||
DateTimeOffset? DisabledUntilUtc,
|
||||
bool DisabledManually,
|
||||
string? DisabledReason,
|
||||
|
||||
@@ -378,7 +378,7 @@ internal sealed class ManagementStore
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT disabled_until_utc, disabled_manually, disabled_reason
|
||||
SELECT disabled_until_utc, disabled_manually, disabled_reason, disabled_from_utc
|
||||
FROM client_controls
|
||||
WHERE client_id = $client_id
|
||||
""";
|
||||
@@ -393,18 +393,22 @@ internal sealed class ManagementStore
|
||||
var disabledUntil = ReadNullableDateTimeOffset(reader, 0);
|
||||
var disabledManually = reader.GetInt32(1) != 0;
|
||||
var reason = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
var disabledFrom = ReadNullableDateTimeOffset(reader, 3);
|
||||
|
||||
if (disabledManually)
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isScheduled = disabledFrom > now;
|
||||
|
||||
if (disabledManually && !isScheduled)
|
||||
{
|
||||
return new ClientAccess(true, null, true, reason);
|
||||
return new ClientAccess(true, disabledFrom, null, true, reason);
|
||||
}
|
||||
|
||||
if (disabledUntil is { } until && until > DateTimeOffset.UtcNow)
|
||||
if (!isScheduled && disabledUntil is { } until && until > now)
|
||||
{
|
||||
return new ClientAccess(true, until, false, reason);
|
||||
return new ClientAccess(true, disabledFrom, until, false, reason);
|
||||
}
|
||||
|
||||
return ClientAccess.Enabled;
|
||||
return new ClientAccess(false, disabledFrom, disabledUntil, false, reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +425,7 @@ internal sealed class ManagementStore
|
||||
{
|
||||
using var connection = OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT client_id, disabled_until_utc, disabled_manually, disabled_reason FROM client_controls";
|
||||
command.CommandText = "SELECT client_id, disabled_until_utc, disabled_manually, disabled_reason, disabled_from_utc FROM client_controls";
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
@@ -430,19 +434,23 @@ internal sealed class ManagementStore
|
||||
var disabledUntil = ReadNullableDateTimeOffset(reader, 1);
|
||||
var disabledManually = reader.GetInt32(2) != 0;
|
||||
var reason = reader.IsDBNull(3) ? null : reader.GetString(3);
|
||||
var disabledFrom = ReadNullableDateTimeOffset(reader, 4);
|
||||
|
||||
result[clientId] = disabledManually
|
||||
? new ClientAccess(true, null, true, reason)
|
||||
: disabledUntil is { } until && until > DateTimeOffset.UtcNow
|
||||
? new ClientAccess(true, until, false, reason)
|
||||
: ClientAccess.Enabled;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var isScheduled = disabledFrom > now;
|
||||
|
||||
result[clientId] = disabledManually && !isScheduled
|
||||
? new ClientAccess(true, disabledFrom, null, true, reason)
|
||||
: !isScheduled && disabledUntil is { } until && until > now
|
||||
? new ClientAccess(true, disabledFrom, until, false, reason)
|
||||
: new ClientAccess(false, disabledFrom, disabledUntil, disabledManually, reason);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void DisableClient(string clientId, TimeSpan? duration, bool manually, string? reason)
|
||||
public void DisableClient(string clientId, TimeSpan? duration, bool manually, string? reason, DateTimeOffset? startAtUtc = null, DateTimeOffset? untilUtc = null)
|
||||
{
|
||||
EnsureAvailable();
|
||||
|
||||
@@ -452,7 +460,9 @@ internal sealed class ManagementStore
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var disabledUntil = manually ? null : now.Add(duration ?? TimeSpan.FromHours(1)).ToString("O");
|
||||
var disabledFrom = startAtUtc?.ToString("O");
|
||||
var disabledUntil = untilUtc?.ToString("O")
|
||||
?? (manually ? null : now.Add(duration ?? TimeSpan.FromHours(1)).ToString("O"));
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -462,23 +472,27 @@ internal sealed class ManagementStore
|
||||
INSERT INTO client_controls (
|
||||
client_id,
|
||||
disabled_until_utc,
|
||||
disabled_from_utc,
|
||||
disabled_manually,
|
||||
disabled_reason,
|
||||
updated_at_utc)
|
||||
VALUES (
|
||||
$client_id,
|
||||
$disabled_until_utc,
|
||||
$disabled_from_utc,
|
||||
$disabled_manually,
|
||||
$disabled_reason,
|
||||
$updated_at_utc)
|
||||
ON CONFLICT(client_id) DO UPDATE SET
|
||||
disabled_until_utc = excluded.disabled_until_utc,
|
||||
disabled_from_utc = excluded.disabled_from_utc,
|
||||
disabled_manually = excluded.disabled_manually,
|
||||
disabled_reason = excluded.disabled_reason,
|
||||
updated_at_utc = excluded.updated_at_utc
|
||||
""";
|
||||
command.Parameters.AddWithValue("$client_id", clientId);
|
||||
command.Parameters.AddWithValue("$disabled_until_utc", (object?)disabledUntil ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$disabled_from_utc", (object?)disabledFrom ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$disabled_manually", manually ? 1 : 0);
|
||||
command.Parameters.AddWithValue("$disabled_reason", string.IsNullOrWhiteSpace(reason) ? DBNull.Value : reason.Trim());
|
||||
command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O"));
|
||||
@@ -503,17 +517,20 @@ internal sealed class ManagementStore
|
||||
INSERT INTO client_controls (
|
||||
client_id,
|
||||
disabled_until_utc,
|
||||
disabled_from_utc,
|
||||
disabled_manually,
|
||||
disabled_reason,
|
||||
updated_at_utc)
|
||||
VALUES (
|
||||
$client_id,
|
||||
NULL,
|
||||
NULL,
|
||||
0,
|
||||
NULL,
|
||||
$updated_at_utc)
|
||||
ON CONFLICT(client_id) DO UPDATE SET
|
||||
disabled_until_utc = NULL,
|
||||
disabled_from_utc = NULL,
|
||||
disabled_manually = 0,
|
||||
disabled_reason = NULL,
|
||||
updated_at_utc = excluded.updated_at_utc
|
||||
@@ -1997,6 +2014,7 @@ internal sealed class ManagementStore
|
||||
CREATE TABLE IF NOT EXISTS client_controls (
|
||||
client_id TEXT NOT NULL PRIMARY KEY,
|
||||
disabled_until_utc TEXT NULL,
|
||||
disabled_from_utc TEXT NULL,
|
||||
disabled_manually INTEGER NOT NULL DEFAULT 0,
|
||||
disabled_reason TEXT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
@@ -2306,6 +2324,21 @@ internal sealed class ManagementStore
|
||||
}
|
||||
}
|
||||
|
||||
using (var migrate = connection.CreateCommand())
|
||||
{
|
||||
migrate.CommandText = """
|
||||
SELECT COUNT(*) FROM pragma_table_info('client_controls') WHERE name = 'disabled_from_utc'
|
||||
""";
|
||||
var hasFromColumn = (long)migrate.ExecuteScalar()! > 0;
|
||||
|
||||
if (!hasFromColumn)
|
||||
{
|
||||
using var addFromCol = connection.CreateCommand();
|
||||
addFromCol.CommandText = "ALTER TABLE client_controls ADD COLUMN disabled_from_utc TEXT NULL";
|
||||
addFromCol.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded {ClientKeyCount} client key(s) from {DatabasePath}.",
|
||||
_clientKeysByHash.Count,
|
||||
@@ -2406,11 +2439,12 @@ internal sealed class ManagementStore
|
||||
|
||||
internal sealed record ClientAccess(
|
||||
bool IsDisabled,
|
||||
DateTimeOffset? DisabledFromUtc,
|
||||
DateTimeOffset? DisabledUntilUtc,
|
||||
bool DisabledManually,
|
||||
string? DisabledReason)
|
||||
{
|
||||
public static ClientAccess Enabled { get; } = new(false, null, false, null);
|
||||
public static ClientAccess Enabled { get; } = new(false, null, null, false, null);
|
||||
}
|
||||
|
||||
internal sealed record UserKeyInfo(
|
||||
|
||||
@@ -373,14 +373,26 @@ internal static class ReverseProxyEndpoint
|
||||
|
||||
private static string GetClientDisabledMessage(string clientId, ClientAccess access)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(access.DisabledReason)
|
||||
? ""
|
||||
: $" Reason: {access.DisabledReason.Trim()}.";
|
||||
|
||||
if (access.DisabledManually)
|
||||
{
|
||||
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.";
|
||||
return $"Tunnel client '{clientId}' is disabled until it is enabled manually.{reason}";
|
||||
}
|
||||
|
||||
return access.DisabledUntilUtc is { } disabledUntil
|
||||
? $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}."
|
||||
: $"Tunnel client '{clientId}' is disabled.";
|
||||
if (access.DisabledUntilUtc is { } disabledUntil)
|
||||
{
|
||||
var from = access.DisabledFromUtc is { } fromUtc
|
||||
? $" (scheduled from {fromUtc:O})"
|
||||
: "";
|
||||
return $"Tunnel client '{clientId}' is disabled until {disabledUntil:O}.{from}{reason}";
|
||||
}
|
||||
|
||||
return access.DisabledFromUtc is { } fromUtc2
|
||||
? $"Tunnel client '{clientId}' is disabled (scheduled from {fromUtc2:O}).{reason}"
|
||||
: $"Tunnel client '{clientId}' is disabled.{reason}";
|
||||
}
|
||||
|
||||
private static async Task<string?> GetRequestedModelAsync(HttpRequest request, PathString proxyPath)
|
||||
|
||||
@@ -592,6 +592,71 @@ tr:last-child td {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -94,13 +94,21 @@ content.addEventListener("click", async (event) => {
|
||||
try {
|
||||
setBusy(button, true);
|
||||
|
||||
if (action === "disable-hour") {
|
||||
await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
|
||||
method: "POST",
|
||||
body: { mode: "duration", durationMinutes: 60 }
|
||||
});
|
||||
setNotice(`Disabled ${clientId} for one hour.`);
|
||||
await refresh();
|
||||
if (action === "disable-temporary") {
|
||||
setBusy(button, false);
|
||||
try {
|
||||
const body = await showDisableModal(clientId);
|
||||
setBusy(button, true);
|
||||
await api(`/clients/${encodeURIComponent(clientId)}/disable`, {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
setNotice(`Disabled ${clientId}.`);
|
||||
await refresh();
|
||||
} catch {
|
||||
// cancelled
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "disable-manual") {
|
||||
@@ -497,6 +505,7 @@ function clientsTable(clients) {
|
||||
${client.disabled ? badge(client.disabledManually ? "Disabled manual" : "Disabled timed", "bad") : badge("Enabled", "good")}
|
||||
</div>
|
||||
${client.disabled ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
||||
${isScheduled(client) ? `<div class="cell-sub">${escapeHtml(disabledText(client))}</div>` : ""}
|
||||
</td>
|
||||
<td>${number(client.pendingRequests)}</td>
|
||||
<td>
|
||||
@@ -512,7 +521,7 @@ function clientsTable(clients) {
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button>
|
||||
<button class="button warning" data-action="disable-temporary" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable temporary</button>
|
||||
<button class="button warning" data-action="disable-manual" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable</button>
|
||||
<button class="button secondary" data-action="enable-client" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "" : "disabled"}>Enable</button>
|
||||
</div>
|
||||
@@ -1656,11 +1665,24 @@ function emptyState(text) {
|
||||
}
|
||||
|
||||
function disabledText(client) {
|
||||
var text = "";
|
||||
if (client.disabledManually) {
|
||||
return "Until enabled manually";
|
||||
text = "Until enabled manually";
|
||||
} else if (client.disabledUntilUtc) {
|
||||
text = `Until ${formatDate(client.disabledUntilUtc)}`;
|
||||
} else if (isScheduled(client)) {
|
||||
text = `Scheduled from ${formatDate(client.disabledFromUtc)}`;
|
||||
} else {
|
||||
text = "Disabled";
|
||||
}
|
||||
if (client.disabledReason) {
|
||||
text += ` — ${escapeHtml(client.disabledReason)}`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return client.disabledUntilUtc ? `Until ${formatDate(client.disabledUntilUtc)}` : "Disabled";
|
||||
function isScheduled(client) {
|
||||
return client.disabledFromUtc && new Date(client.disabledFromUtc) > new Date();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
@@ -1722,4 +1744,127 @@ function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function showDisableModal(clientId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-dialog">
|
||||
<h3>Disable client "${escapeHtml(clientId)}"</h3>
|
||||
<div class="field">
|
||||
<span class="field-label">When to disable</span>
|
||||
<div class="radio-row">
|
||||
<label><input type="radio" name="d-when" value="now" checked> Now</label>
|
||||
<label><input type="radio" name="d-when" value="later"> Later</label>
|
||||
</div>
|
||||
<input type="datetime-local" id="d-from" class="input" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">For how long</span>
|
||||
<div class="radio-row">
|
||||
<label><input type="radio" name="d-for" value="timespan" checked> Timespan</label>
|
||||
<label><input type="radio" name="d-for" value="until"> Until</label>
|
||||
</div>
|
||||
<div id="d-timespan-group" class="field-row">
|
||||
<input type="number" id="d-duration" class="input" value="1" min="1" style="width:100px">
|
||||
<select id="d-unit" class="select" style="width:auto">
|
||||
<option value="1">minute(s)</option>
|
||||
<option value="60" selected>hour(s)</option>
|
||||
<option value="1440">day(s)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="datetime-local" id="d-until" class="input" style="display:none" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="d-reason">Reason (optional)</label>
|
||||
<input type="text" id="d-reason" class="input" placeholder="e.g. maintenance">
|
||||
</div>
|
||||
<div class="form-row modal-actions">
|
||||
<button class="button secondary" id="d-cancel" type="button">Cancel</button>
|
||||
<button class="button warning" id="d-confirm" type="button">Disable</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add("visible"));
|
||||
|
||||
const whenRadios = overlay.querySelectorAll('input[name="d-when"]');
|
||||
const forRadios = overlay.querySelectorAll('input[name="d-for"]');
|
||||
const fromInput = overlay.querySelector("#d-from");
|
||||
const timespanGroup = overlay.querySelector("#d-timespan-group");
|
||||
const untilInput = overlay.querySelector("#d-until");
|
||||
|
||||
function updateWhen() {
|
||||
const val = overlay.querySelector('input[name="d-when"]:checked').value;
|
||||
fromInput.style.display = val === "later" ? "" : "none";
|
||||
fromInput.disabled = val !== "later";
|
||||
if (val === "now") fromInput.value = "";
|
||||
}
|
||||
|
||||
function updateFor() {
|
||||
const val = overlay.querySelector('input[name="d-for"]:checked').value;
|
||||
timespanGroup.style.display = val === "timespan" ? "flex" : "none";
|
||||
untilInput.style.display = val === "until" ? "" : "none";
|
||||
untilInput.disabled = val !== "until";
|
||||
if (val === "timespan") untilInput.value = "";
|
||||
}
|
||||
|
||||
whenRadios.forEach(r => r.addEventListener("change", updateWhen));
|
||||
forRadios.forEach(r => r.addEventListener("change", updateFor));
|
||||
updateFor();
|
||||
|
||||
overlay.querySelector("#d-confirm").addEventListener("click", () => {
|
||||
const when = overlay.querySelector('input[name="d-when"]:checked').value;
|
||||
const forVal = overlay.querySelector('input[name="d-for"]:checked').value;
|
||||
const reason = overlay.querySelector("#d-reason").value.trim() || null;
|
||||
|
||||
if (when === "later" && !fromInput.value) {
|
||||
setNotice("Please select a date and time for the disable.", true);
|
||||
return;
|
||||
}
|
||||
if (forVal === "until" && !untilInput.value) {
|
||||
setNotice("Please select a date and time for the end.", true);
|
||||
return;
|
||||
}
|
||||
if (forVal === "timespan") {
|
||||
const val = parseInt(overlay.querySelector("#d-duration").value);
|
||||
if (!val || val < 1) {
|
||||
setNotice("Please enter a valid duration.", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const body = { reason };
|
||||
|
||||
if (when === "later") {
|
||||
body.startAtUtc = new Date(fromInput.value).toISOString();
|
||||
}
|
||||
|
||||
if (forVal === "timespan") {
|
||||
const val = parseInt(overlay.querySelector("#d-duration").value) || 1;
|
||||
const unit = parseInt(overlay.querySelector("#d-unit").value);
|
||||
body.durationMinutes = val * unit;
|
||||
} else {
|
||||
body.untilUtc = new Date(untilInput.value).toISOString();
|
||||
}
|
||||
|
||||
overlay.remove();
|
||||
resolve(body);
|
||||
});
|
||||
|
||||
overlay.querySelector("#d-cancel").addEventListener("click", () => {
|
||||
overlay.remove();
|
||||
reject(new Error("Cancelled"));
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) {
|
||||
overlay.remove();
|
||||
reject(new Error("Cancelled"));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user