e968566e9d
- CreateDefaultHttpClient sets AllowAutoRedirect=false so a 307/308 cannot re-POST the /auth/token credential form elsewhere. - A forced re-exchange after a 401 carries the refused token; inside the gate it reuses a token another caller already re-exchanged instead of binding again. The BasicOnly switch is written under the same gate. - SCADABRIDGE_VERBOSE=1 prints a stderr note when a fresh token is refused and the CLI falls back to Basic. - debug stream's AccessTokenProvider retries the exchange once and documents the null-token degradation (the hub refuses, automatic reconnect retries). - Tests: concurrent refusal reuses the peer refresh (2 exchanges, not 3); real-socket redirect is not followed.
328 lines
14 KiB
C#
328 lines
14 KiB
C#
using System.CommandLine;
|
|
using System.CommandLine.Parsing;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.SignalR.Client;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
|
|
|
public static class DebugCommands
|
|
{
|
|
/// <summary>Builds the <c>debug</c> command with its subcommands using the given shared CLI options.</summary>
|
|
/// <param name="urlOption">Shared management URL option.</param>
|
|
/// <param name="formatOption">Shared output format option.</param>
|
|
/// <param name="usernameOption">Shared username option for authentication.</param>
|
|
/// <param name="passwordOption">Shared password option for authentication.</param>
|
|
/// <returns>The configured <c>debug</c> command with snapshot and stream subcommands registered.</returns>
|
|
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var command = new Command("debug") { Description = "Runtime debugging" };
|
|
|
|
command.Add(BuildSnapshot(urlOption, formatOption, usernameOption, passwordOption));
|
|
command.Add(BuildStream(urlOption, formatOption, usernameOption, passwordOption));
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildSnapshot(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
|
var cmd = new Command("snapshot") { Description = "Get a point-in-time snapshot of instance attribute values and alarm states" };
|
|
cmd.Add(idOption);
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
return await CommandHelpers.ExecuteCommandAsync(
|
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
|
new DebugSnapshotCommand(result.GetValue(idOption)));
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
private static Command BuildStream(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
|
{
|
|
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
|
var cmd = new Command("stream") { Description = "Stream live attribute values and alarm states in real-time (Ctrl+C to stop)" };
|
|
cmd.Add(idOption);
|
|
cmd.SetAction(async (ParseResult result) =>
|
|
{
|
|
var instanceId = result.GetValue(idOption);
|
|
var config = CliConfig.Load();
|
|
var format = CommandHelpers.ResolveFormat(result, formatOption, config);
|
|
|
|
var url = result.GetValue(urlOption);
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
url = config.ManagementUrl;
|
|
|
|
if (string.IsNullOrWhiteSpace(url))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
|
|
"NO_URL");
|
|
return 1;
|
|
}
|
|
|
|
if (!CommandHelpers.IsValidManagementUrl(url))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
$"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).",
|
|
"INVALID_URL");
|
|
return 1;
|
|
}
|
|
|
|
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
|
|
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
OutputFormatter.WriteError(
|
|
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
|
|
"NO_CREDENTIALS");
|
|
return 1;
|
|
}
|
|
|
|
return await StreamDebugAsync(url, username, password, instanceId, format);
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
|
|
{
|
|
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
|
|
|
|
// Exchange the credentials for a bearer token once (review 2026-10-21 05/U1, 05/P1) so
|
|
// the hub handshake — and every automatic reconnect — costs no LDAP bind. The hub
|
|
// authenticates through ManagementAuthenticator, which accepts the token in the
|
|
// Authorization header; the SignalR .NET client sends the AccessTokenProvider value
|
|
// there on negotiate and on the WebSocket upgrade, and re-invokes the provider on each
|
|
// reconnect, so a token that expired mid-stream is re-exchanged. A server without
|
|
// bearer support (or SCADABRIDGE_AUTH_MODE=basic) keeps the Basic header.
|
|
using var authClient = new ManagementHttpClient(baseUrl, username, password);
|
|
ManagementHttpClient.BearerResult bearer;
|
|
try
|
|
{
|
|
bearer = await authClient.TryGetBearerTokenAsync(CancellationToken.None);
|
|
}
|
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
|
{
|
|
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
|
return 1;
|
|
}
|
|
|
|
if (bearer.Failure is not null)
|
|
{
|
|
OutputFormatter.WriteError(bearer.Failure.Error ?? "Authentication failed.", bearer.Failure.ErrorCode ?? "AUTH_FAILED");
|
|
return 1;
|
|
}
|
|
|
|
var connection = new HubConnectionBuilder()
|
|
.WithUrl(hubUrl, options =>
|
|
{
|
|
if (bearer.Token is not null)
|
|
{
|
|
// Re-invoked on every (re)connect. A null token means the exchange fell back
|
|
// (transient error, or the server dropped bearer support mid-stream): retry
|
|
// once, and if it is still null connect without a token — the hub refuses the
|
|
// handshake and WithAutomaticReconnect tries again on its backoff schedule.
|
|
options.AccessTokenProvider = async () =>
|
|
(await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token
|
|
?? (await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token;
|
|
}
|
|
else
|
|
{
|
|
options.Headers.Add("Authorization", $"Basic {authClient.BasicCredentials}");
|
|
}
|
|
})
|
|
.WithAutomaticReconnect()
|
|
.Build();
|
|
|
|
// CancellationTokenSource owns a WaitHandle and must be disposed.
|
|
using var cts = new CancellationTokenSource();
|
|
var exitTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
Console.CancelKeyPress += (_, e) =>
|
|
{
|
|
e.Cancel = true;
|
|
cts.Cancel();
|
|
};
|
|
|
|
var isTable = string.Equals(format, "table", StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Register event handlers
|
|
connection.On<JsonElement>("OnSnapshot", snapshot =>
|
|
{
|
|
if (isTable)
|
|
{
|
|
Console.WriteLine("=== Initial Snapshot ===");
|
|
PrintSnapshotTable(snapshot);
|
|
Console.WriteLine("=== Streaming (Ctrl+C to stop) ===");
|
|
}
|
|
else
|
|
{
|
|
var obj = new { type = "snapshot", data = snapshot };
|
|
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
|
}
|
|
});
|
|
|
|
connection.On<JsonElement>("OnAttributeChanged", changed =>
|
|
{
|
|
if (isTable)
|
|
{
|
|
var name = changed.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
|
|
var value = changed.TryGetProperty("value", out var v) ? v.ToString() : "?";
|
|
var quality = changed.TryGetProperty("quality", out var q) ? q.GetString() : "?";
|
|
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
|
Console.WriteLine($" ATTR {name,-30} {value,-20} {quality,-10} {ts}");
|
|
}
|
|
else
|
|
{
|
|
var obj = new { type = "attributeChanged", data = changed };
|
|
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
|
}
|
|
});
|
|
|
|
connection.On<JsonElement>("OnAlarmChanged", changed =>
|
|
{
|
|
if (isTable)
|
|
{
|
|
var name = changed.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
|
|
var state = changed.TryGetProperty("state", out var s) ? s.ToString() : "?";
|
|
var priority = changed.TryGetProperty("priority", out var p) ? p.ToString() : "?";
|
|
var ts = changed.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
|
Console.WriteLine($" ALARM {name,-30} {state,-20} P{priority,-9} {ts}");
|
|
}
|
|
else
|
|
{
|
|
var obj = new { type = "alarmChanged", data = changed };
|
|
Console.WriteLine(JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = false }));
|
|
}
|
|
});
|
|
|
|
connection.On<string>("OnStreamTerminated", reason =>
|
|
{
|
|
Console.Error.WriteLine($"Stream terminated: {reason}");
|
|
exitTcs.TrySetResult(1);
|
|
});
|
|
|
|
connection.Closed += ex =>
|
|
{
|
|
if (!cts.IsCancellationRequested)
|
|
{
|
|
Console.Error.WriteLine(ex != null
|
|
? $"Connection lost: {ex.Message}"
|
|
: "Connection closed.");
|
|
}
|
|
exitTcs.TrySetResult(cts.IsCancellationRequested ? 0 : 1);
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
connection.Reconnecting += ex =>
|
|
{
|
|
Console.Error.WriteLine($"Reconnecting... ({ex?.Message})");
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
connection.Reconnected += _ =>
|
|
{
|
|
Console.Error.WriteLine("Reconnected. Re-subscribing...");
|
|
return connection.InvokeAsync("SubscribeInstance", instanceId);
|
|
};
|
|
|
|
// Connect and subscribe
|
|
try
|
|
{
|
|
await connection.StartAsync(cts.Token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Ctrl+C during connect throws OperationCanceledException — that is
|
|
// a graceful user cancellation, not a connection failure.
|
|
var failure = DebugStreamHelpers.ClassifyConnectFailure(ex, cts.IsCancellationRequested);
|
|
if (failure.IsCancellation)
|
|
{
|
|
await connection.DisposeAsync();
|
|
return failure.ExitCode;
|
|
}
|
|
|
|
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
|
await connection.DisposeAsync();
|
|
return failure.ExitCode;
|
|
}
|
|
|
|
try
|
|
{
|
|
await connection.InvokeAsync("SubscribeInstance", instanceId, cts.Token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
OutputFormatter.WriteError($"Subscribe failed: {ex.Message}", "SUBSCRIBE_FAILED");
|
|
await connection.DisposeAsync();
|
|
return 1;
|
|
}
|
|
|
|
if (isTable)
|
|
{
|
|
Console.WriteLine($"Connected to instance {instanceId}. Waiting for data...");
|
|
}
|
|
|
|
// Wait for cancellation (Ctrl+C) or stream termination
|
|
try
|
|
{
|
|
await exitTcs.Task.WaitAsync(cts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Ctrl+C — graceful shutdown
|
|
}
|
|
|
|
try
|
|
{
|
|
await connection.InvokeAsync("UnsubscribeInstance");
|
|
}
|
|
catch
|
|
{
|
|
// Best effort
|
|
}
|
|
|
|
await connection.DisposeAsync();
|
|
|
|
// Resolve the exit code from a single authoritative source. A result
|
|
// set by OnStreamTerminated/Closed always wins; a brief grace period covers a
|
|
// termination racing with Ctrl+C. Pure Ctrl+C (no result) is a graceful exit 0.
|
|
return await DebugStreamHelpers.ResolveStreamExitCodeAsync(exitTcs.Task);
|
|
}
|
|
|
|
private static void PrintSnapshotTable(JsonElement snapshot)
|
|
{
|
|
if (snapshot.TryGetProperty("attributeValues", out var attrs) && attrs.ValueKind == JsonValueKind.Array)
|
|
{
|
|
Console.WriteLine(" Attributes:");
|
|
Console.WriteLine($" {"Name",-30} {"Value",-20} {"Quality",-10} Timestamp");
|
|
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
|
|
foreach (var av in attrs.EnumerateArray())
|
|
{
|
|
var name = av.TryGetProperty("attributeName", out var n) ? n.GetString() : "?";
|
|
var value = av.TryGetProperty("value", out var v) ? v.ToString() : "?";
|
|
var quality = av.TryGetProperty("quality", out var q) ? q.GetString() : "?";
|
|
var ts = av.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
|
Console.WriteLine($" {name,-30} {value,-20} {quality,-10} {ts}");
|
|
}
|
|
}
|
|
|
|
if (snapshot.TryGetProperty("alarmStates", out var alarms) && alarms.ValueKind == JsonValueKind.Array)
|
|
{
|
|
Console.WriteLine(" Alarms:");
|
|
Console.WriteLine($" {"Name",-30} {"State",-20} {"Priority",-10} Timestamp");
|
|
Console.WriteLine($" {new string('-', 30)} {new string('-', 20)} {new string('-', 10)} {new string('-', 25)}");
|
|
foreach (var al in alarms.EnumerateArray())
|
|
{
|
|
var name = al.TryGetProperty("alarmName", out var n) ? n.GetString() : "?";
|
|
var state = al.TryGetProperty("state", out var s) ? s.ToString() : "?";
|
|
var priority = al.TryGetProperty("priority", out var p) ? p.ToString() : "?";
|
|
var ts = al.TryGetProperty("timestamp", out var t) ? t.GetString() : "?";
|
|
Console.WriteLine($" {name,-30} {state,-20} P{priority,-9} {ts}");
|
|
}
|
|
}
|
|
}
|
|
}
|