feat(cli): read secret-bearing option values from a file or stdin (#137)

Every CLI option that can carry a secret gets a `--<name>-file <path|->`
sibling ('-' reads stdin), so the value stays out of the process list and
shell history: --password-file (global), --primary-config-file /
--backup-config-file (data-connection create|update), --auth-config-file
(external-system create|update), --connection-string-file (db-connection
create|update), --credentials-file (notification smtp create|update) and
--auth-token-file (notification sms create|update). Follows the existing
--config-file convention; `@path` is avoided because System.CommandLine
expands @-tokens as response files.

The value is read at parse time; a missing/unreadable/empty source, or '-'
on a terminal stdin, is a parse error naming option + path only, never the
content. Inline and file forms are mutually exclusive, at most one option
may read stdin, and one trailing line break is stripped. Inline forms keep
working.

docker/seed-sites.sh and docker-env2/seed-sites.sh now write the MxGateway
config (windev gateway key) to a mode-600 temp file removed by an EXIT trap
and pass --primary-config-file; seed-flow-outbound-demo.sh pipes its rig
connection string via --connection-string-file -. CLI README (new "Secrets
from a file or stdin" section + per-command tables), Component-CLI.md and
both docker READMEs updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-13 00:46:17 -05:00
parent 1e98997ce1
commit e2f433e849
19 changed files with 724 additions and 52 deletions
@@ -50,6 +50,9 @@ public static class CliApplication
rootCommand.Add(SecuredWriteCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
rootCommand.Add(FlowDebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
// After every group: the --password / --password-file exclusivity check is per command.
CliOptions.AddPasswordFileOption(rootCommand, passwordOption);
rootCommand.SetAction(_ =>
{
Console.WriteLine("Use --help to see available commands.");
@@ -88,12 +88,12 @@ public static class AuditCommandHelpers
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
var password = CommandHelpers.ResolvePassword(result, passwordOption, config);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return AuditConnection.Fail(
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"Credentials required. Use --username with --password or --password-file, or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
}
@@ -18,6 +18,40 @@ internal static class CliOptions
/// than silently falling through to JSON.
/// </summary>
/// <returns>The configured <c>--format</c> option constrained to "json" or "table".</returns>
/// <summary>
/// The global <c>--password-file</c> option: reads the LDAP password from a file or stdin
/// (ScadaBridge#137). Shared by every root so <see cref="CommandHelpers.ResolvePassword"/>
/// can read it without threading another option through each command group.
/// </summary>
internal static Option<string?> PasswordFileOption { get; } =
ValueSourceOptions.CreateFileOption("--password", "the LDAP password", recursive: true);
/// <summary>
/// Adds <see cref="PasswordFileOption"/> to the root and rejects it alongside an inline
/// <c>--password</c> on every command. Call it after every command group is added.
/// </summary>
/// <param name="root">The CLI root command.</param>
/// <param name="passwordOption">The inline <c>--password</c> option already on the root.</param>
internal static void AddPasswordFileOption(Command root, Option<string> passwordOption)
{
root.Add(PasswordFileOption);
// System.CommandLine runs only the innermost command's validators, so the check goes on
// every command in the tree. Call this after all command groups are registered.
void AddTo(Command command)
{
command.Validators.Add(result =>
{
if (result.GetResult(passwordOption) is { Implicit: false } && result.GetResult(PasswordFileOption) is { Implicit: false })
result.AddError("Use either --password or --password-file, not both.");
});
foreach (var sub in command.Subcommands)
AddTo(sub);
}
AddTo(root);
}
internal static Option<string> CreateFormatOption()
{
var formatOption = new Option<string>("--format")
@@ -75,12 +75,12 @@ internal static class CommandHelpers
// Resolve credentials: command-line options take precedence, then the
// SCADABRIDGE_USERNAME / SCADABRIDGE_PASSWORD environment variables.
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
var password = ResolvePassword(result, passwordOption, config);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"Credentials required. Use --username with --password or --password-file, or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -145,6 +145,19 @@ internal static class CommandHelpers
/// <param name="commandLineValue">Value supplied on the command line, or null if absent.</param>
/// <param name="envValue">Fallback value from the config file or environment variable.</param>
/// <returns>The command-line value when non-empty; otherwise the environment fallback (may be null).</returns>
/// <summary>
/// Resolves the LDAP password: <c>--password-file</c> (file or stdin), then
/// <c>--password</c>, then <c>SCADABRIDGE_PASSWORD</c>.
/// </summary>
/// <param name="result">Parsed command-line result.</param>
/// <param name="passwordOption">The inline <c>--password</c> option.</param>
/// <param name="config">Loaded CLI configuration providing the environment fallback.</param>
/// <returns>The resolved password, or null when none was supplied.</returns>
internal static string? ResolvePassword(ParseResult result, Option<string> passwordOption, CliConfig config)
=> result.GetResult(CliOptions.PasswordFileOption) is { Implicit: false }
? result.GetValue(CliOptions.PasswordFileOption)
: ResolveCredential(result.GetValue(passwordOption), config.Password);
internal static string? ResolveCredential(string? commandLineValue, string? envValue)
=> string.IsNullOrWhiteSpace(commandLineValue) ? envValue : commandLineValue;
@@ -65,6 +65,8 @@ public static class DataConnectionCommands
var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true };
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var configFileOption = ValueSourceOptions.CreateFileOption(configOption, "the primary configuration JSON");
var backupConfigFileOption = ValueSourceOptions.CreateFileOption(backupConfigOption, "the backup configuration JSON");
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("update") { Description = "Update a data connection" };
@@ -74,13 +76,14 @@ public static class DataConnectionCommands
cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
ValueSourceOptions.AddFileOptions(cmd, (configOption, configFileOption, false), (backupConfigOption, backupConfigFileOption, false));
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var config = ValueSourceOptions.GetValue(result, configOption, configFileOption);
var backupConfig = ValueSourceOptions.GetValue(result, backupConfigOption, backupConfigFileOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -110,6 +113,8 @@ public static class DataConnectionCommands
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
var configFileOption = ValueSourceOptions.CreateFileOption(configOption, "the primary configuration JSON");
var backupConfigFileOption = ValueSourceOptions.CreateFileOption(backupConfigOption, "the backup configuration JSON");
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
var cmd = new Command("create") { Description = "Create a new data connection" };
@@ -119,13 +124,14 @@ public static class DataConnectionCommands
cmd.Add(configOption);
cmd.Add(backupConfigOption);
cmd.Add(failoverRetryOption);
ValueSourceOptions.AddFileOptions(cmd, (configOption, configFileOption, false), (backupConfigOption, backupConfigFileOption, false));
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
var name = result.GetValue(nameOption)!;
var protocol = result.GetValue(protocolOption)!;
var config = result.GetValue(configOption);
var backupConfig = result.GetValue(backupConfigOption);
var config = ValueSourceOptions.GetValue(result, configOption, configFileOption);
var backupConfig = ValueSourceOptions.GetValue(result, backupConfigOption, backupConfigFileOption);
var failoverRetryCount = result.GetValue(failoverRetryOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -56,15 +56,17 @@ public static class DbConnectionCommands
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string (or use --connection-string-file)" };
var connStrFileOption = ValueSourceOptions.CreateFileOption(connStrOption, "the connection string");
var cmd = new Command("create") { Description = "Create a database connection" };
cmd.Add(nameOption);
cmd.Add(connStrOption);
ValueSourceOptions.AddFileOptions(cmd, (connStrOption, connStrFileOption, true));
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var connStr = result.GetValue(connStrOption)!;
var connStr = ValueSourceOptions.GetValue(result, connStrOption, connStrFileOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new CreateDatabaseConnectionDefCommand(name, connStr));
@@ -76,17 +78,19 @@ public static class DbConnectionCommands
{
var idOption = new Option<int>("--id") { Description = "Database connection ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string", Required = true };
var connStrOption = new Option<string>("--connection-string") { Description = "Connection string (or use --connection-string-file)" };
var connStrFileOption = ValueSourceOptions.CreateFileOption(connStrOption, "the connection string");
var cmd = new Command("update") { Description = "Update a database connection" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(connStrOption);
ValueSourceOptions.AddFileOptions(cmd, (connStrOption, connStrFileOption, true));
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var connStr = result.GetValue(connStrOption)!;
var connStr = ValueSourceOptions.GetValue(result, connStrOption, connStrFileOption)!;
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new UpdateDatabaseConnectionDefCommand(id, name, connStr));
@@ -71,12 +71,12 @@ public static class DebugCommands
}
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
var password = CommandHelpers.ResolvePassword(result, passwordOption, config);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"Credentials required. Use --username with --password or --password-file, or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -49,6 +49,7 @@ public static class ExternalSystemCommands
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type", Required = true };
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
var authConfigFileOption = ValueSourceOptions.CreateFileOption(authConfigOption, "the auth configuration JSON");
var timeoutOption = new Option<int?>("--timeout-seconds") { Description = "Per-system HTTP timeout in seconds (0 = use gateway default); omit to preserve" };
var maxRetriesOption = new Option<int?>("--max-retries") { Description = "Store-and-forward retry budget for cached calls (0 = use engine default); omit to preserve" };
var retryDelayOption = new Option<int?>("--retry-delay-seconds") { Description = "Fixed interval between store-and-forward retries, in seconds (0 = use engine default); omit to preserve" };
@@ -63,13 +64,14 @@ public static class ExternalSystemCommands
cmd.Add(timeoutOption);
cmd.Add(maxRetriesOption);
cmd.Add(retryDelayOption);
ValueSourceOptions.AddFileOptions(cmd, (authConfigOption, authConfigFileOption, false));
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var name = result.GetValue(nameOption)!;
var endpointUrl = result.GetValue(endpointUrlOption)!;
var authType = result.GetValue(authTypeOption)!;
var authConfig = result.GetValue(authConfigOption);
var authConfig = ValueSourceOptions.GetValue(result, authConfigOption, authConfigFileOption);
var timeoutSeconds = result.GetValue(timeoutOption);
var maxRetries = result.GetValue(maxRetriesOption);
var retryDelaySeconds = result.GetValue(retryDelayOption);
@@ -99,6 +101,7 @@ public static class ExternalSystemCommands
var endpointUrlOption = new Option<string>("--endpoint-url") { Description = "Endpoint URL", Required = true };
var authTypeOption = new Option<string>("--auth-type") { Description = "Auth type (ApiKey, BasicAuth)", Required = true };
var authConfigOption = new Option<string?>("--auth-config") { Description = "Auth configuration JSON" };
var authConfigFileOption = ValueSourceOptions.CreateFileOption(authConfigOption, "the auth configuration JSON");
var timeoutOption = new Option<int?>("--timeout-seconds") { Description = "Per-system HTTP timeout in seconds (0 = use gateway default)" };
var maxRetriesOption = new Option<int?>("--max-retries") { Description = "Store-and-forward retry budget for cached calls (0 = use engine default)" };
var retryDelayOption = new Option<int?>("--retry-delay-seconds") { Description = "Fixed interval between store-and-forward retries, in seconds (0 = use engine default)" };
@@ -112,12 +115,13 @@ public static class ExternalSystemCommands
cmd.Add(timeoutOption);
cmd.Add(maxRetriesOption);
cmd.Add(retryDelayOption);
ValueSourceOptions.AddFileOptions(cmd, (authConfigOption, authConfigFileOption, false));
cmd.SetAction(async (ParseResult result) =>
{
var name = result.GetValue(nameOption)!;
var endpointUrl = result.GetValue(endpointUrlOption)!;
var authType = result.GetValue(authTypeOption)!;
var authConfig = result.GetValue(authConfigOption);
var authConfig = ValueSourceOptions.GetValue(result, authConfigOption, authConfigFileOption);
var timeoutSeconds = result.GetValue(timeoutOption);
var maxRetries = result.GetValue(maxRetriesOption);
var retryDelaySeconds = result.GetValue(retryDelayOption);
@@ -182,9 +182,11 @@ public static class NotificationCommands
var createCmd = new Command("create") { Description = "Create SMTP configuration" };
foreach (var option in new Option[] { SmtpServerOption, SmtpPortOption, SmtpAuthModeOption, SmtpFromOption, SmtpTlsModeOption,
SmtpCredentialsOption, SmtpOAuth2AuthorityOption, SmtpOAuth2ScopeOption, SmtpTransportOption }) createCmd.Add(option);
ValueSourceOptions.AddFileOptions(createCmd, (SmtpCredentialsOption, SmtpCredentialsFileOption, false));
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmtpConfigCommand(result.GetValue(SmtpServerOption)!, result.GetValue(SmtpPortOption), result.GetValue(SmtpAuthModeOption)!,
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption), result.GetValue(SmtpCredentialsOption),
result.GetValue(SmtpFromOption)!, result.GetValue(SmtpTlsModeOption),
ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption),
result.GetValue(SmtpOAuth2AuthorityOption), result.GetValue(SmtpOAuth2ScopeOption), result.GetValue(SmtpTransportOption))));
group.Add(createCmd);
@@ -199,6 +201,7 @@ public static class NotificationCommands
updateCmd.Add(SmtpOAuth2AuthorityOption);
updateCmd.Add(SmtpOAuth2ScopeOption);
updateCmd.Add(SmtpTransportOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmtpCredentialsOption, SmtpCredentialsFileOption, false));
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -229,6 +232,9 @@ public static class NotificationCommands
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
"for OAuth2 (optional; preserves existing if omitted)",
};
// Declared after SmtpCredentialsOption: static initialisers run in textual order.
private static readonly Option<string?> SmtpCredentialsFileOption =
ValueSourceOptions.CreateFileOption(SmtpCredentialsOption, "the SMTP credentials");
private static readonly Option<string?> SmtpOAuth2AuthorityOption =
new("--oauth2-authority")
{
@@ -282,7 +288,7 @@ public static class NotificationCommands
var authMode = result.GetValue(SmtpAuthModeOption)!;
var from = result.GetValue(SmtpFromOption)!;
var tlsMode = result.GetValue(SmtpTlsModeOption);
var credentials = result.GetValue(SmtpCredentialsOption);
var credentials = ValueSourceOptions.GetValue(result, SmtpCredentialsOption, SmtpCredentialsFileOption);
var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption);
var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption);
var transport = result.GetValue(SmtpTransportOption);
@@ -304,15 +310,18 @@ public static class NotificationCommands
var createCmd = new Command("create") { Description = "Create SMS configuration (Auth Token required)" };
foreach (var option in new Option[] { SmsAccountSidOption, SmsFromNumberOption, SmsMessagingServiceSidOption, SmsApiBaseUrlOption,
SmsAuthTokenOption, SmsTimeoutOption, SmsMaxRetriesOption, SmsRetryDelayOption }) createCmd.Add(option);
ValueSourceOptions.AddFileOptions(createCmd, (SmsAuthTokenOption, SmsAuthTokenFileOption, false));
createCmd.Validators.Add(result =>
{
if (string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption))) result.AddError("--auth-token is required when creating SMS configuration.");
// A file-supplied token that failed to read already carries its own parse error.
if (result.GetResult(SmsAuthTokenFileOption) is null && string.IsNullOrWhiteSpace(result.GetValue(SmsAuthTokenOption)))
result.AddError("--auth-token (or --auth-token-file) is required when creating SMS configuration.");
if (string.IsNullOrWhiteSpace(result.GetValue(SmsFromNumberOption)) && string.IsNullOrWhiteSpace(result.GetValue(SmsMessagingServiceSidOption)))
result.AddError("Provide --from-number and/or --messaging-service-sid.");
});
createCmd.SetAction(async result => await CommandHelpers.ExecuteCommandAsync(result, urlOption, formatOption, usernameOption, passwordOption,
new CreateSmsConfigCommand(result.GetValue(SmsAccountSidOption)!, result.GetValue(SmsFromNumberOption), result.GetValue(SmsMessagingServiceSidOption),
result.GetValue(SmsApiBaseUrlOption), result.GetValue(SmsAuthTokenOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsApiBaseUrlOption), ValueSourceOptions.GetValue(result, SmsAuthTokenOption, SmsAuthTokenFileOption), result.GetValue(SmsTimeoutOption),
result.GetValue(SmsMaxRetriesOption), SmsRetryDelay(result))));
group.Add(createCmd);
@@ -326,6 +335,7 @@ public static class NotificationCommands
updateCmd.Add(SmsTimeoutOption);
updateCmd.Add(SmsMaxRetriesOption);
updateCmd.Add(SmsRetryDelayOption);
ValueSourceOptions.AddFileOptions(updateCmd, (SmsAuthTokenOption, SmsAuthTokenFileOption, false));
updateCmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
@@ -381,6 +391,9 @@ public static class NotificationCommands
{
Description = "Twilio Auth Token (optional; PRESERVES the stored token if omitted; never printed back)",
};
// Declared after SmsAuthTokenOption: static initialisers run in textual order.
private static readonly Option<string?> SmsAuthTokenFileOption =
ValueSourceOptions.CreateFileOption(SmsAuthTokenOption, "the Twilio Auth Token");
/// <summary>
/// Builds the <see cref="UpdateSmsConfigCommand"/> from a parsed <c>sms update</c>
@@ -398,7 +411,7 @@ public static class NotificationCommands
var fromNumber = result.GetValue(SmsFromNumberOption);
var messagingServiceSid = result.GetValue(SmsMessagingServiceSidOption);
var apiBaseUrl = result.GetValue(SmsApiBaseUrlOption);
var authToken = result.GetValue(SmsAuthTokenOption);
var authToken = ValueSourceOptions.GetValue(result, SmsAuthTokenOption, SmsAuthTokenFileOption);
// Whole-replace update: the sender identity must be re-supplied. A Twilio config
// needs a From number and/or a Messaging Service SID, so reject the case where the
// update would leave neither (mirrors the UI + delivery-adapter validation).
@@ -0,0 +1,187 @@
using System.CommandLine;
using System.CommandLine.Parsing;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Gives a secret-bearing option a <c>--&lt;name&gt;-file</c> sibling so its value can come from a
/// file or from stdin instead of the command line, where any local user can read it from the
/// process list and where it lands in shell history (ScadaBridge#137).
/// </summary>
/// <remarks>
/// <para>Convention: <c>--primary-config-file &lt;path&gt;</c> reads the value from
/// <c>&lt;path&gt;</c>; <c>--primary-config-file -</c> reads it from stdin (a file literally
/// named <c>-</c> is reached as <c>./-</c>). One trailing line break is stripped, so a file
/// written by <c>echo</c> works for a connection string or token. The inline option keeps
/// working; giving both is a parse error, as is reading stdin for two options at once.</para>
/// <para>This follows the CLI's existing <c>--config-file</c> / <c>--file</c> options rather than a
/// curl-style <c>@path</c> value, because System.CommandLine already treats a token that starts
/// with <c>@</c> as a response file and would expand it before the option ever saw it.</para>
/// <para>The value is read while parsing, so a missing or unreadable file is reported as an
/// ordinary parse error. Error text names the option and the path only — never the content.</para>
/// </remarks>
internal static class ValueSourceOptions
{
/// <summary>The value that selects stdin instead of a file path.</summary>
internal const string StdinMarker = "-";
/// <summary>Opens stdin for reading. Replaceable by tests.</summary>
internal static Func<TextReader> StdinReader { get; set; } = () => Console.In;
/// <summary>Whether stdin is redirected (piped or from a file). Replaceable by tests.</summary>
internal static Func<bool> StdinIsRedirected { get; set; } = () => Console.IsInputRedirected;
/// <summary>
/// Creates the <c>--&lt;inline&gt;-file</c> sibling of <paramref name="inlineOption"/>.
/// </summary>
/// <param name="inlineOption">The inline option the file option stands in for.</param>
/// <param name="what">Short description of the value, used in the help text.</param>
/// <returns>An option whose parsed value is the file (or stdin) content.</returns>
internal static Option<string?> CreateFileOption(Option inlineOption, string what)
=> CreateFileOption(inlineOption.Name, what, inlineOption.Recursive);
/// <summary>Creates the <c>--&lt;inline&gt;-file</c> option for an inline option known by name.</summary>
/// <param name="inlineName">The inline option's name, including the leading dashes.</param>
/// <param name="what">Short description of the value, used in the help text.</param>
/// <param name="recursive">Whether the option applies to every subcommand.</param>
/// <returns>An option whose parsed value is the file (or stdin) content.</returns>
internal static Option<string?> CreateFileOption(string inlineName, string what, bool recursive = false)
{
var name = inlineName + "-file";
return new Option<string?>(name)
{
Description = $"Read {what} from a file instead of the command line ('-' reads stdin); " +
$"keeps secrets out of the process list and shell history. Replaces {inlineName}.",
Recursive = recursive,
CustomParser = argument => Read(argument, name),
};
}
/// <summary>
/// Adds each file option to <paramref name="command"/> and a validator that rejects an inline
/// value given together with its file sibling, more than one option reading stdin, and — for a
/// required pair — neither form given.
/// </summary>
/// <param name="command">The command that already holds the inline options.</param>
/// <param name="pairs">Each inline option, its file sibling, and whether one of the two is required.</param>
internal static void AddFileOptions(Command command, params (Option Inline, Option<string?> File, bool Required)[] pairs)
{
foreach (var pair in pairs)
command.Add(pair.File);
command.Validators.Add(result =>
{
var stdinUsers = new List<string>();
foreach (var (inline, file, required) in pairs)
{
var inlineGiven = IsGiven(result.GetResult(inline));
var fileResult = result.GetResult(file);
var fileGiven = IsGiven(fileResult);
if (inlineGiven && fileGiven)
result.AddError($"Use either {inline.Name} or {file.Name}, not both.");
else if (required && !inlineGiven && !fileGiven)
result.AddError($"Option '{inline.Name}' (or '{file.Name}') is required.");
if (fileGiven && ReadsStdin(fileResult!))
stdinUsers.Add(file.Name);
}
// The global --password-file can read stdin too.
var passwordFile = result.GetResult(CliOptions.PasswordFileOption);
if (IsGiven(passwordFile) && ReadsStdin(passwordFile!))
stdinUsers.Add(CliOptions.PasswordFileOption.Name);
if (stdinUsers.Count > 1)
result.AddError($"Only one option can read stdin ('-'); got {string.Join(", ", stdinUsers)}.");
});
}
/// <summary>
/// Returns the value from <paramref name="file"/> when it was given, otherwise the inline value.
/// </summary>
/// <param name="result">The parse result.</param>
/// <param name="inline">The inline option.</param>
/// <param name="file">Its file sibling.</param>
/// <returns>The resolved value, or null when neither was given.</returns>
internal static string? GetValue(ParseResult result, Option inline, Option<string?> file)
=> IsGiven(result.GetResult(file))
? result.GetValue(file)
: result.GetResult(inline)?.GetValueOrDefault<string?>();
private static bool IsGiven(OptionResult? optionResult)
=> optionResult is { Implicit: false };
private static bool ReadsStdin(OptionResult optionResult)
=> optionResult.Tokens.Count == 1 && optionResult.Tokens[0].Value == StdinMarker;
private static string? Read(ArgumentResult argument, string optionName)
{
if (argument.Tokens.Count != 1)
{
argument.AddError($"{optionName} takes exactly one path (or '-' for stdin).");
return null;
}
var path = argument.Tokens[0].Value;
string content;
try
{
if (path == StdinMarker)
{
if (!StdinIsRedirected())
{
argument.AddError($"{optionName} -: stdin is a terminal; pipe or redirect the value in.");
return null;
}
content = StdinReader().ReadToEnd();
}
else
{
content = File.ReadAllText(path);
}
}
// Each message names the option and path only. The exception's own message is not
// used: it is short today, but the content must never be able to reach the output.
catch (FileNotFoundException)
{
argument.AddError($"{optionName}: file '{path}' not found.");
return null;
}
catch (DirectoryNotFoundException)
{
argument.AddError($"{optionName}: file '{path}' not found.");
return null;
}
catch (UnauthorizedAccessException)
{
argument.AddError($"{optionName}: file '{path}' cannot be read (access denied, or it is a directory).");
return null;
}
catch (IOException)
{
argument.AddError($"{optionName}: file '{path}' cannot be read.");
return null;
}
content = StripOneTrailingLineBreak(content);
if (content.Length == 0)
{
argument.AddError(path == StdinMarker
? $"{optionName} -: stdin was empty."
: $"{optionName}: file '{path}' is empty.");
return null;
}
return content;
}
private static string StripOneTrailingLineBreak(string content)
{
if (content.EndsWith("\r\n", StringComparison.Ordinal))
return content[..^2];
if (content.EndsWith('\n'))
return content[..^1];
return content;
}
}
+70 -14
View File
@@ -39,6 +39,7 @@ These options are accepted by the root command and inherited by all subcommands.
| `--url <value>` | Management API URL (e.g., `http://localhost:9001`) |
| `--username <value>` | LDAP username for authentication |
| `--password <value>` | LDAP password for authentication |
| `--password-file <path\|->` | Read the LDAP password from a file, or from stdin with `-` (see *Secrets from a file or stdin*). Not together with `--password`. |
| `--format <json\|table>` | Output format (default: `json`) |
## Configuration File
@@ -60,9 +61,54 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
| `SCADABRIDGE_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when neither `--password` nor `--password-file` is supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). |
## Secrets from a file or stdin
Every option that can carry a secret has a `-file` sibling that reads the value from a file, or from
stdin when the path is `-`, so the secret never appears on the command line. A value on the command
line can be read by any local user from the process list (`ps`) and lands in shell history.
| Inline option | File / stdin sibling | Commands |
|---------------|----------------------|----------|
| `--password` | `--password-file` | every command (global) |
| `--primary-config` (`--configuration`) | `--primary-config-file` | `data-connection create`, `data-connection update` |
| `--backup-config` | `--backup-config-file` | `data-connection create`, `data-connection update` |
| `--auth-config` | `--auth-config-file` | `external-system create`, `external-system update` |
| `--connection-string` | `--connection-string-file` | `db-connection create`, `db-connection update` |
| `--credentials` | `--credentials-file` | `notification smtp create`, `notification smtp update` |
| `--auth-token` | `--auth-token-file` | `notification sms create`, `notification sms update` |
Rules:
- `--x-file <path>` reads the whole file; `--x-file -` reads stdin. A file literally named `-` is `./-`.
- One trailing line break is removed, so a file written with `echo` works for a connection string or token.
- The inline form keeps working for non-secret values. Giving both forms of the same value is an error,
and so is more than one option reading stdin in one invocation.
- A missing, unreadable or empty file, or `-` with stdin attached to a terminal, is a parse error that
names the option and the path. No error message ever includes the file's content.
- The value is sent to the management API exactly as the inline form would send it — the file is only
a different way to hand it to the CLI.
- `@path` is **not** a file reference: System.CommandLine treats a token that starts with `@` as a
response file and expands it before any option sees it.
```sh
# MxGateway config with its API key, from a mode-600 file
(umask 077; printf '{"endpoint":"http://gw:5120","apiKey":"%s"}' "$MXGW_APIKEY" > /tmp/mxgw.json)
scadabridge data-connection create --site-id 1 --name "Gateway" --protocol MxGateway --primary-config-file /tmp/mxgw.json
rm -f /tmp/mxgw.json
# Connection string from stdin
printf '%s' "$CONN_STR" | scadabridge db-connection create --name PlantDb --connection-string-file -
# LDAP password from a file
scadabridge --password-file ~/.config/scadabridge/password site list
```
`printf` is a shell builtin in bash and zsh, so the secret is not in any process's argument list while
it is written either. Using `echo` or `cat <<<` is equally fine; `python3 -c ... "$SECRET"` is not.
## Output
All commands write JSON to stdout on success. Errors are written as JSON to stderr:
@@ -1201,7 +1247,7 @@ scadabridge --url <url> data-connection list [--site-id <int>]
Create a new data connection belonging to a specific site.
```sh
scadabridge --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--primary-config <json>] [--backup-config <json>] [--failover-retry-count <int>]
scadabridge --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--primary-config <json> | --primary-config-file <path|->] [--backup-config <json> | --backup-config-file <path|->] [--failover-retry-count <int>]
```
| Option | Required | Description |
@@ -1210,7 +1256,9 @@ scadabridge --url <url> data-connection create --site-id <int> --name <string> -
| `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
| `--primary-config` | no | Primary protocol-specific configuration as a JSON string (alias `--configuration`) |
| `--primary-config-file` | no | Read the primary configuration from a file, or stdin with `-` — use it when the config carries a secret (MxGateway `apiKey`, OPC UA password). Not together with `--primary-config`. |
| `--backup-config` | no | Backup protocol-specific configuration as a JSON string |
| `--backup-config-file` | no | Read the backup configuration from a file, or stdin with `-`. Not together with `--backup-config`. |
| `--failover-retry-count` | no | Retries before failover to the backup configuration (default: `3`) |
#### `data-connection update`
@@ -1219,7 +1267,7 @@ Update a data connection definition. An update **replaces** the whole entity —
required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> data-connection update --id <int> --name <string> --protocol <string> [--primary-config <json>] [--backup-config <json>] [--failover-retry-count <int>]
scadabridge --url <url> data-connection update --id <int> --name <string> --protocol <string> [--primary-config <json> | --primary-config-file <path|->] [--backup-config <json> | --backup-config-file <path|->] [--failover-retry-count <int>]
```
| Option | Required | Description |
@@ -1228,7 +1276,9 @@ scadabridge --url <url> data-connection update --id <int> --name <string> --prot
| `--name` | yes | Connection name |
| `--protocol` | yes | Protocol identifier |
| `--primary-config` | no | Primary protocol-specific configuration as a JSON string (alias `--configuration`) |
| `--primary-config-file` | no | Read the primary configuration from a file, or stdin with `-` — use it when the config carries a secret (MxGateway `apiKey`, OPC UA password). Not together with `--primary-config`. |
| `--backup-config` | no | Backup protocol-specific configuration as a JSON string |
| `--backup-config-file` | no | Read the backup configuration from a file, or stdin with `-`. Not together with `--backup-config`. |
| `--failover-retry-count` | no | Retries before failover to the backup configuration (default: `3`) |
#### `data-connection move`
@@ -1369,7 +1419,7 @@ scadabridge --url <url> external-system list
Register an external HTTP system that scripts can call.
```sh
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
scadabridge --url <url> external-system create --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json> | --auth-config-file <path|->] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
```
| Option | Required | Description |
@@ -1378,6 +1428,7 @@ scadabridge --url <url> external-system create --name <string> --endpoint-url <u
| `--endpoint-url` | yes | Base URL of the external system |
| `--auth-type` | yes | Authentication type: `ApiKey` or `BasicAuth` |
| `--auth-config` | no | Auth credentials as a JSON string |
| `--auth-config-file` | no | Read the auth configuration from a file, or stdin with `-`. Not together with `--auth-config`. |
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout. `0` (default) = use the gateway's `DefaultHttpTimeout` |
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s. `0` (default) = use the engine's `DefaultMaxRetries` (50) |
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries. `0` (default) = use the engine's `DefaultRetryInterval` (30 s). Alias: `--retry-delay` |
@@ -1392,7 +1443,7 @@ Update an external system definition. An update **replaces** the whole entity
required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json>] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
scadabridge --url <url> external-system update --id <int> --name <string> --endpoint-url <url> --auth-type <string> [--auth-config <json> | --auth-config-file <path|->] [--timeout-seconds <int>] [--max-retries <int>] [--retry-delay-seconds <int>]
```
| Option | Required | Description |
@@ -1402,6 +1453,7 @@ scadabridge --url <url> external-system update --id <int> --name <string> --endp
| `--endpoint-url` | yes | Base URL |
| `--auth-type` | yes | Authentication type |
| `--auth-config` | no | Updated auth credentials as a JSON string; omit to preserve the stored value |
| `--auth-config-file` | no | Read the auth configuration from a file, or stdin with `-`. Not together with `--auth-config`. |
| `--timeout-seconds` | no | Per-system HTTP round-trip timeout; omit to preserve |
| `--max-retries` | no | Store-and-forward retry budget for this system's `CachedCall`s; omit to preserve |
| `--retry-delay-seconds` | no | Fixed interval between store-and-forward retries; omit to preserve. Alias: `--retry-delay` |
@@ -1587,7 +1639,7 @@ scadabridge --url <url> notification smtp list
Create a new SMTP configuration. Requires Administrator access.
```sh
scadabridge notification smtp create --server <host> --port <int> --auth-mode <mode> --from-address <email> [--tls-mode <mode>] [--credentials <value>] [--oauth2-authority <url>] [--oauth2-scope <scope>] [--transport <Smtp|Ews>]
scadabridge notification smtp create --server <host> --port <int> --auth-mode <mode> --from-address <email> [--tls-mode <mode>] [--credentials <value> | --credentials-file <path|->] [--oauth2-authority <url>] [--oauth2-scope <scope>] [--transport <Smtp|Ews>]
```
Uses the same settings as `smtp update`, without `--id`. This sends the distinct create contract; update with ID zero is not a creation operation. The server validates transport/authentication compatibility.
@@ -1597,7 +1649,7 @@ Uses the same settings as `smtp update`, without `--id`. This sends the distinct
Update the SMTP configuration.
```sh
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string>] [--oauth2-authority <string>] [--oauth2-scope <string>] [--transport <string>]
scadabridge --url <url> notification smtp update --id <int> --server <string> --port <int> --auth-mode <string> --from-address <string> [--tls-mode <string>] [--credentials <string> | --credentials-file <path|->] [--oauth2-authority <string>] [--oauth2-scope <string>] [--transport <string>]
```
| Option | Required | Description |
@@ -1609,6 +1661,7 @@ scadabridge --url <url> notification smtp update --id <int> --server <string> --
| `--from-address` | yes | Sender email address |
| `--tls-mode` | no | TLS mode: `None`, `StartTLS`, or `SSL` (preserves existing if omitted) |
| `--credentials` | no | SMTP credentials — `username:password` for Basic, or client secret for OAuth2 (preserves existing if omitted) |
| `--credentials-file` | no | Read the credentials from a file, or stdin with `-`. Not together with `--credentials`. |
| `--oauth2-authority` | no | OAuth2 token-endpoint URL — a `{tenant}` placeholder is substituted from the credential (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
| `--oauth2-scope` | no | OAuth2 scope requested from the token endpoint (preserves existing / Microsoft 365 default if omitted; OAuth2 auth mode only) |
| `--transport` | no | Email transport: `Smtp` or `Ews` (preserves existing if omitted) |
@@ -1630,17 +1683,17 @@ scadabridge --url <url> notification sms list
Create a new Twilio configuration. Requires Administrator access.
```sh
scadabridge notification sms create --account-sid <sid> --auth-token <token> [--from-number <E.164-number>] [--messaging-service-sid <sid>] [--api-base-url <url>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
scadabridge notification sms create --account-sid <sid> (--auth-token <token> | --auth-token-file <path|->) [--from-number <E.164-number>] [--messaging-service-sid <sid>] [--api-base-url <url>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
```
Account SID and auth token are required. Supply at least one sender: `--from-number` or `--messaging-service-sid`. Timeout must be positive; retries/delay must be nonnegative. Omitted policy settings use creation defaults. Explicit zero retry settings retain the outbox fallback semantics; they do not disable retries.
Account SID and auth token are required (the token inline, or from a file / stdin with `--auth-token-file`). Supply at least one sender: `--from-number` or `--messaging-service-sid`. Timeout must be positive; retries/delay must be nonnegative. Omitted policy settings use creation defaults. Explicit zero retry settings retain the outbox fallback semantics; they do not disable retries.
#### `notification sms update`
Update the SMS (Twilio) configuration.
```sh
scadabridge --url <url> notification sms update --id <int> --account-sid <string> [--from-number <string>] [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string>] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
scadabridge --url <url> notification sms update --id <int> --account-sid <string> [--from-number <string>] [--messaging-service-sid <string>] [--api-base-url <string>] [--auth-token <string> | --auth-token-file <path|->] [--connection-timeout-seconds <n>] [--max-retries <n>] [--retry-delay-seconds <n>]
```
| Option | Required | Description |
@@ -1651,6 +1704,7 @@ scadabridge --url <url> notification sms update --id <int> --account-sid <string
| `--messaging-service-sid` | no | Twilio Messaging Service SID. **Omitting it CLEARS the stored value** (the update overwrites it). |
| `--api-base-url` | no | API base URL override (preserves existing if omitted) |
| `--auth-token` | no | Twilio Auth Token. **Omitting it PRESERVES the stored token.** Never printed back. |
| `--auth-token-file` | no | Read the Auth Token from a file, or stdin with `-`. Not together with `--auth-token`. |
| `--connection-timeout-seconds` | no | Positive timeout; omission preserves the existing value |
| `--max-retries` | no | Nonnegative retry setting; omission preserves it; zero retains outbox fallback semantics |
| `--retry-delay-seconds` | no | Nonnegative delay; omission preserves it; zero retains outbox fallback semantics |
@@ -2385,13 +2439,14 @@ scadabridge --url <url> db-connection get --id <int>
Create a new database connection definition.
```sh
scadabridge --url <url> db-connection create --name <string> --connection-string <string>
scadabridge --url <url> db-connection create --name <string> (--connection-string <string> | --connection-string-file <path|->)
```
| Option | Required | Description |
|--------|----------|-------------|
| `--name` | yes | Connection name |
| `--connection-string` | yes | Database connection string |
| `--connection-string` | one of | Database connection string |
| `--connection-string-file` | one of | Read the connection string from a file, or stdin with `-`. Exactly one of the two is required. |
#### `db-connection update`
@@ -2399,14 +2454,15 @@ Update a database connection definition. An update **replaces** the whole entity
every required field below must be supplied, even if unchanged.
```sh
scadabridge --url <url> db-connection update --id <int> --name <string> --connection-string <string>
scadabridge --url <url> db-connection update --id <int> --name <string> (--connection-string <string> | --connection-string-file <path|->)
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Database connection ID |
| `--name` | yes | Connection name |
| `--connection-string` | yes | Database connection string |
| `--connection-string` | one of | Database connection string |
| `--connection-string-file` | one of | Read the connection string from a file, or stdin with `-`. Exactly one of the two is required. |
#### `db-connection delete`