using System.CommandLine; using System.CommandLine.Parsing; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// /// CLI parity for the site-routed OPC UA data-connection operations that the /// ManagementActor dispatches (arch-review C4): live address-space browse and /// search, endpoint verify-endpoint probing, and server-certificate trust /// management (certs list|trust|remove). /// /// /// These subcommands attach under the existing data-connection group. Every /// command carries an explicit --site (mapped to the command record's /// SiteIdentifier) so the management API can route it to the target site cluster /// — the site side ignores it once routing has happened. browse/search and /// verify-endpoint are Designer operations; certs are Admin operations, /// enforced server-side by the ManagementActor. /// public static class ConnectionBrowseCommands { /// /// Attaches the browse / search / verify-endpoint / certs subcommands to the /// data-connection command group. /// /// The existing data-connection group to extend. /// Global management URL option. /// Global output format option. /// Global username option. /// Global password option. /// /// Test seam: when supplied, the built command record is handed to this delegate /// (which returns the process exit code) instead of being sent over HTTP. Lets parse /// tests assert the exact record produced from a given command line without a live /// management server. Null in production (Program.cs), where the real HTTP path runs. /// public static void AddTo( Command dataConnection, Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? commandInterceptor = null) { dataConnection.Add(BuildBrowse(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor)); dataConnection.Add(BuildSearch(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor)); dataConnection.Add(BuildVerifyEndpoint(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor)); dataConnection.Add(BuildCerts(urlOption, formatOption, usernameOption, passwordOption, commandInterceptor)); } /// /// Sends to the management API, or — when a test /// interceptor is present — hands it to the interceptor instead. /// private static Task DispatchAsync( ParseResult result, Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, object command, Func? commandInterceptor) { if (commandInterceptor is not null) return Task.FromResult(commandInterceptor(command)); return CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, command); } private static Command BuildBrowse(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; var connectionOption = new Option("--connection") { Description = "Data connection name", Required = true }; var nodeIdOption = new Option("--node-id") { Description = "Parent node id to browse (server root / ObjectsFolder if omitted)" }; var cmd = new Command("browse") { Description = "Browse the immediate children of an OPC UA node on a site data connection" }; cmd.Add(siteOption); cmd.Add(connectionOption); cmd.Add(nodeIdOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; var connection = result.GetValue(connectionOption)!; var nodeId = result.GetValue(nodeIdOption); return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new BrowseNodeCommand(connection, nodeId, null, site), interceptor); }); return cmd; } private static Command BuildSearch(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; var connectionOption = new Option("--connection") { Description = "Data connection name", Required = true }; var queryOption = new Option("--query") { Description = "Case-insensitive substring matched on DisplayName and root-relative path", Required = true }; var maxDepthOption = new Option("--max-depth") { Description = "Maximum levels below the root to descend", DefaultValueFactory = _ => 5 }; var maxResultsOption = new Option("--max-results") { Description = "Maximum number of matches to return", DefaultValueFactory = _ => 100 }; var cmd = new Command("search") { Description = "Bounded recursive search of an OPC UA address space on a site data connection" }; cmd.Add(siteOption); cmd.Add(connectionOption); cmd.Add(queryOption); cmd.Add(maxDepthOption); cmd.Add(maxResultsOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; var connection = result.GetValue(connectionOption)!; var query = result.GetValue(queryOption)!; var maxDepth = result.GetValue(maxDepthOption); var maxResults = result.GetValue(maxResultsOption); return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new SearchAddressSpaceCommand(connection, query, maxDepth, maxResults, site), interceptor); }); return cmd; } private static Command BuildVerifyEndpoint(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; var protocolOption = new Option("--protocol") { Description = "Protocol type (e.g. OpcUa)", Required = true }; var configFileOption = new Option("--config-file") { Description = "Path to the endpoint configuration JSON file (read verbatim into ConfigJson)", Required = true }; // Correlation only — a verify probe runs against an endpoint config that may not // yet correspond to a saved connection, so the name is optional. var connectionOption = new Option("--connection") { Description = "Connection name for logging/correlation (optional)" }; var cmd = new Command("verify-endpoint") { Description = "Probe a data-connection endpoint config on a site without persisting it (read-only; never trusts the server cert)" }; cmd.Add(siteOption); cmd.Add(protocolOption); cmd.Add(configFileOption); cmd.Add(connectionOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; var protocol = result.GetValue(protocolOption)!; var configFile = result.GetValue(configFileOption)!; var connection = result.GetValue(connectionOption); var configJson = await File.ReadAllTextAsync(configFile); return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new VerifyEndpointCommand(connection ?? string.Empty, protocol, configJson, site), interceptor); }); return cmd; } private static Command BuildCerts(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var group = new Command("certs") { Description = "Manage trusted OPC UA server certificates for a site" }; group.Add(BuildCertsList(urlOption, formatOption, usernameOption, passwordOption, interceptor)); group.Add(BuildCertsTrust(urlOption, formatOption, usernameOption, passwordOption, interceptor)); group.Add(BuildCertsRemove(urlOption, formatOption, usernameOption, passwordOption, interceptor)); return group; } private static Command BuildCertsList(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; // Contextual only — the trusted-peer PKI store is site-scoped (not per-connection), // so the connection is not part of the ListServerCertsCommand payload. Accepted for // symmetry with the connection-scoped UI navigation. var connectionOption = new Option("--connection") { Description = "Data connection name (contextual; not part of the request)" }; var cmd = new Command("list") { Description = "List the certificates in a site's trusted-peer and rejected PKI stores" }; cmd.Add(siteOption); cmd.Add(connectionOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new ListServerCertsCommand(site), interceptor); }); return cmd; } private static Command BuildCertsTrust(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; var connectionOption = new Option("--connection") { Description = "Data connection the certificate was captured from (correlation)", Required = true }; var derFileOption = new Option("--der-file") { Description = "Path to the DER-encoded certificate file (base64-encoded into the request)", Required = true }; var thumbprintOption = new Option("--thumbprint") { Description = "Certificate thumbprint (used as the store filename key)", Required = true }; var cmd = new Command("trust") { Description = "Trust an OPC UA server certificate on every node of a site" }; cmd.Add(siteOption); cmd.Add(connectionOption); cmd.Add(derFileOption); cmd.Add(thumbprintOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; var connection = result.GetValue(connectionOption)!; var derFile = result.GetValue(derFileOption)!; var thumbprint = result.GetValue(thumbprintOption)!; var derBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync(derFile)); return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new TrustServerCertCommand(connection, derBase64, thumbprint, site), interceptor); }); return cmd; } private static Command BuildCertsRemove(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption, Func? interceptor) { var siteOption = new Option("--site") { Description = "Target site identifier", Required = true }; var thumbprintOption = new Option("--thumbprint") { Description = "Thumbprint of the certificate to remove", Required = true }; // Contextual only — RemoveServerCertCommand is keyed by thumbprint + site, not connection. var connectionOption = new Option("--connection") { Description = "Data connection name (contextual; not part of the request)" }; var cmd = new Command("remove") { Description = "Remove a trusted OPC UA server certificate from every node of a site" }; cmd.Add(siteOption); cmd.Add(thumbprintOption); cmd.Add(connectionOption); cmd.SetAction(async (ParseResult result) => { var site = result.GetValue(siteOption)!; var thumbprint = result.GetValue(thumbprintOption)!; return await DispatchAsync(result, urlOption, formatOption, usernameOption, passwordOption, new RemoveServerCertCommand(thumbprint, site), interceptor); }); return cmd; } }