Example #1
0
 public IotedgedLinux(string archivePath, Option <RegistryCredentials> credentials, Option <HttpUris> httpUris, UriSocks uriSocks, Option <string> proxy, Option <UpstreamProtocolType> upstreamProtocol, bool requireEdgeInstallation)
 {
     this.archivePath             = archivePath;
     this.credentials             = credentials;
     this.httpUris                = httpUris;
     this.uriSocks                = uriSocks;
     this.proxy                   = proxy;
     this.upstreamProtocol        = upstreamProtocol;
     this.requireEdgeInstallation = requireEdgeInstallation;
 }
Example #2
0
        public async Task Configure(
            DeviceProvisioningMethod method,
            Option <string> agentImage,
            string hostname,
            Option <string> parentHostname,
            string deviceCaCert,
            string deviceCaPk,
            string deviceCaCerts,
            LogLevel runtimeLogLevel)
        {
            agentImage.ForEach(
                image =>
            {
                Console.WriteLine($"Setting up aziot-edged with agent image {image}");
            },
                () =>
            {
                Console.WriteLine("Setting up aziot-edged with agent image 1.0");
            });

            const string KEYD      = "/etc/aziot/keyd/config.toml";
            const string CERTD     = "/etc/aziot/certd/config.toml";
            const string IDENTITYD = "/etc/aziot/identityd/config.toml";
            const string EDGED     = "/etc/aziot/edged/config.yaml";

            // Initialize each service's config file.
            // The mapped values are:
            // - Path to the config file (/etc/aziot/[service_name]/config.[toml | yaml])
            // - User owning the config file
            // - Template used to generate the config file.
            Dictionary <string, (string owner, IConfigDocument document)> config = new Dictionary <string, (string, IConfigDocument)>();

            config.Add(KEYD, ("aziotks", InitDocument(KEYD + ".default", true)));
            config.Add(CERTD, ("aziotcs", InitDocument(CERTD + ".default", true)));
            config.Add(IDENTITYD, ("aziotid", InitDocument(IDENTITYD + ".default", true)));
            config.Add(EDGED, ("iotedge", InitDocument(EDGED + ".template", false)));

            // Directory for storing keys; create it if it doesn't exist.
            string keyDir = "/var/secrets/aziot/keyd/";

            Directory.CreateDirectory(keyDir);
            SetOwner(keyDir, config[KEYD].owner, "700");

            // Need to always reprovision so previous test runs don't affect this one.
            config[IDENTITYD].document.RemoveIfExists("provisioning");
            config[IDENTITYD].document.ReplaceOrAdd("provisioning.always_reprovision_on_startup", true);

            method.ManualConnectionString.Match(
                cs =>
            {
                string keyPath = Path.Combine(keyDir, "device-id");
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.source", "manual");
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.authentication.method", "sas");
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.authentication.device_id_pk", "device-id");
                config[KEYD].document.ReplaceOrAdd("preloaded_keys.device-id", $"file://{keyPath}");

                string[] segments = cs.Split(";");

                foreach (string s in segments)
                {
                    string[] param = s.Split("=", 2);

                    switch (param[0])
                    {
                    case "HostName":
                        // replace IoTHub hostname with parent hostname for nested edge
                        config[IDENTITYD].document.ReplaceOrAdd("provisioning.iothub_hostname", parentHostname.GetOrElse(param[1]));
                        break;

                    case "SharedAccessKey":
                        File.WriteAllBytes(keyPath, Convert.FromBase64String(param[1]));
                        SetOwner(keyPath, config[KEYD].owner, "600");
                        break;

                    case "DeviceId":
                        config[IDENTITYD].document.ReplaceOrAdd("provisioning.device_id", param[1]);
                        break;

                    default:
                        break;
                    }
                }

                return(string.Empty);
            },
                () =>
            {
                config[IDENTITYD].document.RemoveIfExists("provisioning");
                return(string.Empty);
            });

            method.Dps.ForEach(
                dps =>
            {
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.source", "dps");
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.global_endpoint", dps.EndPoint);
                config[IDENTITYD].document.ReplaceOrAdd("provisioning.scope_id", dps.ScopeId);
                switch (dps.AttestationType)
                {
                case DPSAttestationType.SymmetricKey:
                    string dpsKeyPath = Path.Combine(keyDir, "device-id");
                    string dpsKey     = dps.SymmetricKey.Expect(() => new ArgumentException("Expected symmetric key"));

                    File.WriteAllBytes(dpsKeyPath, Convert.FromBase64String(dpsKey));
                    SetOwner(dpsKeyPath, config[KEYD].owner, "600");

                    config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.method", "symmetric_key");
                    config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.symmetric_key", "device-id");

                    break;

                case DPSAttestationType.X509:
                    string certPath = dps.DeviceIdentityCertificate.Expect(() => new ArgumentException("Expected path to identity certificate"));
                    string keyPath  = dps.DeviceIdentityPrivateKey.Expect(() => new ArgumentException("Expected path to identity private key"));

                    SetOwner(certPath, config[CERTD].owner, "444");
                    SetOwner(keyPath, config[KEYD].owner, "400");

                    config[CERTD].document.ReplaceOrAdd("preloaded_certs.device-id", new Uri(certPath).AbsoluteUri);
                    config[KEYD].document.ReplaceOrAdd("preloaded_keys.device-id", new Uri(keyPath).AbsoluteUri);

                    config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.method", "x509");
                    config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.identity_cert", "device-id");
                    config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.identity_pk", "device-id");
                    break;

                default:
                    break;
                }

                dps.RegistrationId.ForEach(id => { config[IDENTITYD].document.ReplaceOrAdd("provisioning.attestation.registration_id", id); });
            });

            agentImage.ForEach(image =>
            {
                config[EDGED].document.ReplaceOrAdd("agent.config.image", image);
            });

            config[EDGED].document.ReplaceOrAdd("hostname", hostname);
            config[IDENTITYD].document.ReplaceOrAdd("hostname", hostname);

            parentHostname.ForEach(v => config[EDGED].document.ReplaceOrAdd("parent_hostname", v));

            foreach (RegistryCredentials c in this.credentials)
            {
                config[EDGED].document.ReplaceOrAdd("agent.config.auth.serveraddress", c.Address);
                config[EDGED].document.ReplaceOrAdd("agent.config.auth.username", c.User);
                config[EDGED].document.ReplaceOrAdd("agent.config.auth.password", c.Password);
            }

            config[EDGED].document.ReplaceOrAdd("agent.env.RuntimeLogLevel", runtimeLogLevel.ToString());

            if (this.httpUris.HasValue)
            {
                HttpUris uris = this.httpUris.OrDefault();
                config[EDGED].document.ReplaceOrAdd("connect.management_uri", uris.ConnectManagement);
                config[EDGED].document.ReplaceOrAdd("connect.workload_uri", uris.ConnectWorkload);
                config[EDGED].document.ReplaceOrAdd("listen.management_uri", uris.ListenManagement);
                config[EDGED].document.ReplaceOrAdd("listen.workload_uri", uris.ListenWorkload);
            }
            else
            {
                UriSocks socks = this.uriSocks;
                config[EDGED].document.ReplaceOrAdd("connect.management_uri", socks.ConnectManagement);
                config[EDGED].document.ReplaceOrAdd("connect.workload_uri", socks.ConnectWorkload);
                config[EDGED].document.ReplaceOrAdd("listen.management_uri", socks.ListenManagement);
                config[EDGED].document.ReplaceOrAdd("listen.workload_uri", socks.ListenWorkload);
            }

            // Clear any existing Identity Service principals.
            string principalsPath = "/etc/aziot/identityd/config.d";

            config[IDENTITYD].document.RemoveIfExists("principal");
            if (Directory.Exists(principalsPath))
            {
                Directory.Delete(principalsPath, true);
            }

            Directory.CreateDirectory(principalsPath);
            SetOwner(principalsPath, "aziotid", "755");

            // Add the principal entry for aziot-edge to Identity Service.
            // This is required so aziot-edge can communicate with Identity Service.
            uint iotedgeUid = await GetIotedgeUid();

            AddPrincipal("aziot-edge", iotedgeUid);

            foreach (string file in new string[] { deviceCaCert, deviceCaPk, deviceCaCerts })
            {
                if (string.IsNullOrEmpty(file))
                {
                    throw new ArgumentException("device_ca_cert, device_ca_pk, and trusted_ca_certs must all be provided.");
                }

                if (!File.Exists(file))
                {
                    throw new ArgumentException($"{file} does not exist.");
                }
            }

            // Files must be readable by KS and CS users.
            SetOwner(deviceCaCerts, config[CERTD].owner, "444");
            SetOwner(deviceCaCert, config[CERTD].owner, "444");
            SetOwner(deviceCaPk, config[KEYD].owner, "400");

            config[CERTD].document.ReplaceOrAdd("preloaded_certs.aziot-edged-trust-bundle", new Uri(deviceCaCerts).AbsoluteUri);
            config[CERTD].document.ReplaceOrAdd("preloaded_certs.aziot-edged-ca", new Uri(deviceCaCert).AbsoluteUri);
            config[KEYD].document.ReplaceOrAdd("preloaded_keys.aziot-edged-ca", new Uri(deviceCaPk).AbsoluteUri);

            this.proxy.ForEach(proxy => config[EDGED].document.ReplaceOrAdd("agent.env.https_proxy", proxy));

            this.upstreamProtocol.ForEach(upstreamProtocol => config[EDGED].document.ReplaceOrAdd("agent.env.UpstreamProtocol", upstreamProtocol.ToString()));

            foreach (KeyValuePair <string, (string owner, IConfigDocument document)> service in config)
            {
                string path = service.Key;
                string text = service.Value.document.ToString();

                await File.WriteAllTextAsync(path, text);

                SetOwner(path, service.Value.owner, "644");
                Console.WriteLine($"Created config {path}");
            }
        }
Example #3
0
        public async Task Configure(DeviceProvisioningMethod method, Option <string> agentImage, string hostname, string deviceCaCert, string deviceCaPk, string deviceCaCerts, LogLevel runtimeLogLevel)
        {
            agentImage.ForEach(
                image =>
            {
                Console.WriteLine($"Setting up iotedged with agent image {image}");
            },
                () =>
            {
                Console.WriteLine("Setting up iotedged with agent image 1.0");
            });

            const string  YamlPath = "/etc/iotedge/config.yaml";
            Task <string> text     = File.ReadAllTextAsync(YamlPath);
            var           doc      = new YamlDocument(await text);

            method.ManualConnectionString.Match(
                cs =>
            {
                doc.ReplaceOrAdd("provisioning.device_connection_string", cs);
                return(string.Empty);
            },
                () =>
            {
                doc.Remove("provisioning.device_connection_string");
                return(string.Empty);
            });

            method.Dps.ForEach(
                dps =>
            {
                doc.ReplaceOrAdd("provisioning.source", "dps");
                doc.ReplaceOrAdd("provisioning.global_endpoint", dps.EndPoint);
                doc.ReplaceOrAdd("provisioning.scope_id", dps.ScopeId);
                switch (dps.AttestationType)
                {
                case DPSAttestationType.SymmetricKey:
                    doc.ReplaceOrAdd("provisioning.attestation.method", "symmetric_key");
                    doc.ReplaceOrAdd("provisioning.attestation.symmetric_key", dps.SymmetricKey.Expect(() => new ArgumentException("Expected symmetric key")));
                    break;

                case DPSAttestationType.X509:
                    var certUri = new Uri(dps.DeviceIdentityCertificate.Expect(() => new ArgumentException("Expected path to identity certificate")));
                    var keyUri  = new Uri(dps.DeviceIdentityPrivateKey.Expect(() => new ArgumentException("Expected path to identity private key")));
                    doc.ReplaceOrAdd("provisioning.attestation.method", "x509");
                    doc.ReplaceOrAdd("provisioning.attestation.identity_cert", certUri.AbsoluteUri);
                    doc.ReplaceOrAdd("provisioning.attestation.identity_pk", keyUri.AbsoluteUri);
                    break;

                default:
                    doc.ReplaceOrAdd("provisioning.attestation.method", "tpm");
                    break;
                }

                dps.RegistrationId.ForEach(id => { doc.ReplaceOrAdd("provisioning.attestation.registration_id", id); });
            });

            agentImage.ForEach(image =>
            {
                doc.ReplaceOrAdd("agent.config.image", image);
            });

            doc.ReplaceOrAdd("hostname", hostname);

            foreach (RegistryCredentials c in this.credentials)
            {
                doc.ReplaceOrAdd("agent.config.auth.serveraddress", c.Address);
                doc.ReplaceOrAdd("agent.config.auth.username", c.User);
                doc.ReplaceOrAdd("agent.config.auth.password", c.Password);
            }

            doc.ReplaceOrAdd("agent.env.RuntimeLogLevel", runtimeLogLevel.ToString());

            if (this.httpUris.HasValue)
            {
                HttpUris uris = this.httpUris.OrDefault();
                doc.ReplaceOrAdd("connect.management_uri", uris.ConnectManagement);
                doc.ReplaceOrAdd("connect.workload_uri", uris.ConnectWorkload);
                doc.ReplaceOrAdd("listen.management_uri", uris.ListenManagement);
                doc.ReplaceOrAdd("listen.workload_uri", uris.ListenWorkload);
            }
            else
            {
                UriSocks socks = this.uriSocks;
                doc.ReplaceOrAdd("connect.management_uri", socks.ConnectManagement);
                doc.ReplaceOrAdd("connect.workload_uri", socks.ConnectWorkload);
                doc.ReplaceOrAdd("listen.management_uri", socks.ListenManagement);
                doc.ReplaceOrAdd("listen.workload_uri", socks.ListenWorkload);
            }

            if (!string.IsNullOrEmpty(deviceCaCert) && !string.IsNullOrEmpty(deviceCaPk) && !string.IsNullOrEmpty(deviceCaCerts))
            {
                doc.ReplaceOrAdd("certificates.device_ca_cert", deviceCaCert);
                doc.ReplaceOrAdd("certificates.device_ca_pk", deviceCaPk);
                doc.ReplaceOrAdd("certificates.trusted_ca_certs", deviceCaCerts);
            }

            this.proxy.ForEach(proxy => doc.ReplaceOrAdd("agent.env.https_proxy", proxy));

            this.upstreamProtocol.ForEach(upstreamProtocol => doc.ReplaceOrAdd("agent.env.UpstreamProtocol", upstreamProtocol.ToString()));

            string result = doc.ToString();

            FileAttributes attr = 0;

            if (File.Exists(YamlPath))
            {
                attr = File.GetAttributes(YamlPath);
                File.SetAttributes(YamlPath, attr & ~FileAttributes.ReadOnly);
            }

            await File.WriteAllTextAsync(YamlPath, result);

            if (attr != 0)
            {
                File.SetAttributes(YamlPath, attr);
            }
        }
Example #4
0
        public async Task Configure(
            DeviceProvisioningMethod method,
            Option <string> agentImage,
            string hostname,
            Option <string> parentHostname,
            string deviceCaCert,
            string deviceCaPk,
            string deviceCaCerts,
            LogLevel runtimeLogLevel)
        {
            agentImage.ForEach(
                image =>
            {
                Console.WriteLine($"Setting up aziot-edged with agent image {image}");
            },
                () =>
            {
                Console.WriteLine("Setting up aziot-edged with agent image 1.0");
            });

            // Initialize each service's config file.
            Dictionary <string, Config> config = new Dictionary <string, Config>();

            config.Add(KEYD, await InitConfig(KEYD + ".default", "aziotks"));
            config.Add(CERTD, await InitConfig(CERTD + ".default", "aziotcs"));
            config.Add(IDENTITYD, await InitConfig(IDENTITYD + ".default", "aziotid"));
            config.Add(EDGED, await InitConfig(EDGED + ".default", "iotedge"));

            // Directory for storing keys; create it if it doesn't exist.
            string keyDir = "/var/secrets/aziot/keyd/";

            Directory.CreateDirectory(keyDir);
            SetOwner(keyDir, config[KEYD].Owner, "700");

            // Need to always reprovision so previous test runs don't affect this one.
            config[EDGED].Document.ReplaceOrAdd("auto_reprovisioning_mode", "AlwaysOnStartup");
            config[IDENTITYD].Document.RemoveIfExists("provisioning");
            parentHostname.ForEach(
                parent_hostame =>
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.local_gateway_hostname", parent_hostame));

            method.ManualConnectionString.Match(
                cs =>
            {
                string keyPath = Path.Combine(keyDir, "device-id");
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.source", "manual");
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.authentication.method", "sas");
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.authentication.device_id_pk", "device-id");
                config[KEYD].Document.ReplaceOrAdd("preloaded_keys.device-id", $"file://{keyPath}");

                string[] segments = cs.Split(";");

                foreach (string s in segments)
                {
                    string[] param = s.Split("=", 2);

                    switch (param[0])
                    {
                    case "HostName":
                        // replace IoTHub hostname with parent hostname for nested edge
                        config[IDENTITYD].Document.ReplaceOrAdd("provisioning.iothub_hostname", param[1]);
                        break;

                    case "SharedAccessKey":
                        File.WriteAllBytes(keyPath, Convert.FromBase64String(param[1]));
                        SetOwner(keyPath, config[KEYD].Owner, "600");
                        break;

                    case "DeviceId":
                        config[IDENTITYD].Document.ReplaceOrAdd("provisioning.device_id", param[1]);
                        break;

                    default:
                        break;
                    }
                }

                this.SetAuth("device-id", config);

                return(string.Empty);
            },
                () =>
            {
                config[IDENTITYD].Document.RemoveIfExists("provisioning");
                return(string.Empty);
            });

            method.Dps.ForEach(
                dps =>
            {
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.source", "dps");
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.global_endpoint", dps.EndPoint);
                config[IDENTITYD].Document.ReplaceOrAdd("provisioning.scope_id", dps.ScopeId);
                switch (dps.AttestationType)
                {
                case DPSAttestationType.SymmetricKey:
                    string dpsKeyPath = Path.Combine(keyDir, "device-id");
                    string dpsKey     = dps.SymmetricKey.Expect(() => new ArgumentException("Expected symmetric key"));

                    File.WriteAllBytes(dpsKeyPath, Convert.FromBase64String(dpsKey));
                    SetOwner(dpsKeyPath, config[KEYD].Owner, "600");

                    config[KEYD].Document.ReplaceOrAdd("preloaded_keys.device-id", new Uri(dpsKeyPath).AbsoluteUri);
                    config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.method", "symmetric_key");
                    config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.symmetric_key", "device-id");

                    this.SetAuth("device-id", config);

                    break;

                case DPSAttestationType.X509:
                    string certPath = dps.DeviceIdentityCertificate.Expect(() => new ArgumentException("Expected path to identity certificate"));
                    string keyPath  = dps.DeviceIdentityPrivateKey.Expect(() => new ArgumentException("Expected path to identity private key"));

                    SetOwner(certPath, config[CERTD].Owner, "444");
                    SetOwner(keyPath, config[KEYD].Owner, "400");

                    config[CERTD].Document.ReplaceOrAdd("preloaded_certs.device-id", new Uri(certPath).AbsoluteUri);
                    config[KEYD].Document.ReplaceOrAdd("preloaded_keys.device-id", new Uri(keyPath).AbsoluteUri);

                    config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.method", "x509");
                    config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.identity_cert", "device-id");
                    config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.identity_pk", "device-id");

                    this.SetAuth("device-id", config);

                    break;

                default:
                    break;
                }

                dps.RegistrationId.ForEach(id => { config[IDENTITYD].Document.ReplaceOrAdd("provisioning.attestation.registration_id", id); });
            });

            agentImage.ForEach(image =>
            {
                config[EDGED].Document.ReplaceOrAdd("agent.config.image", image);
            });

            config[EDGED].Document.ReplaceOrAdd("hostname", hostname);
            config[IDENTITYD].Document.ReplaceOrAdd("hostname", hostname);

            parentHostname.ForEach(v => config[EDGED].Document.ReplaceOrAdd("parent_hostname", v));

            foreach (RegistryCredentials c in this.credentials)
            {
                config[EDGED].Document.ReplaceOrAdd("agent.config.auth.serveraddress", c.Address);
                config[EDGED].Document.ReplaceOrAdd("agent.config.auth.username", c.User);
                config[EDGED].Document.ReplaceOrAdd("agent.config.auth.password", c.Password);
            }

            config[EDGED].Document.ReplaceOrAdd("agent.env.RuntimeLogLevel", runtimeLogLevel.ToString());

            if (this.httpUris.HasValue)
            {
                HttpUris uris = this.httpUris.OrDefault();
                config[EDGED].Document.ReplaceOrAdd("connect.management_uri", uris.ConnectManagement);
                config[EDGED].Document.ReplaceOrAdd("connect.workload_uri", uris.ConnectWorkload);
                config[EDGED].Document.ReplaceOrAdd("listen.management_uri", uris.ListenManagement);
                config[EDGED].Document.ReplaceOrAdd("listen.workload_uri", uris.ListenWorkload);
            }
            else
            {
                UriSocks socks = this.uriSocks;
                config[EDGED].Document.ReplaceOrAdd("connect.management_uri", socks.ConnectManagement);
                config[EDGED].Document.ReplaceOrAdd("connect.workload_uri", socks.ConnectWorkload);
                config[EDGED].Document.ReplaceOrAdd("listen.management_uri", socks.ListenManagement);
                config[EDGED].Document.ReplaceOrAdd("listen.workload_uri", socks.ListenWorkload);
            }

            foreach (string file in new string[] { deviceCaCert, deviceCaPk, deviceCaCerts })
            {
                if (string.IsNullOrEmpty(file))
                {
                    throw new ArgumentException("device_ca_cert, device_ca_pk, and trusted_ca_certs must all be provided.");
                }

                if (!File.Exists(file))
                {
                    throw new ArgumentException($"{file} does not exist.");
                }
            }

            // Files must be readable by KS and CS users.
            SetOwner(deviceCaCerts, config[CERTD].Owner, "444");
            SetOwner(deviceCaCert, config[CERTD].Owner, "444");
            SetOwner(deviceCaPk, config[KEYD].Owner, "400");

            config[CERTD].Document.ReplaceOrAdd("preloaded_certs.aziot-edged-trust-bundle", new Uri(deviceCaCerts).AbsoluteUri);
            config[CERTD].Document.ReplaceOrAdd("preloaded_certs.aziot-edged-ca", new Uri(deviceCaCert).AbsoluteUri);
            config[KEYD].Document.ReplaceOrAdd("preloaded_keys.aziot-edged-ca", new Uri(deviceCaPk).AbsoluteUri);

            this.proxy.ForEach(proxy => config[EDGED].Document.ReplaceOrAdd("agent.env.https_proxy", proxy));

            this.upstreamProtocol.ForEach(upstreamProtocol => config[EDGED].Document.ReplaceOrAdd("agent.env.UpstreamProtocol", upstreamProtocol.ToString()));

            foreach (KeyValuePair <string, Config> service in config)
            {
                string path = service.Key;
                string text = service.Value.Document.ToString();

                await File.WriteAllTextAsync(path, text);

                SetOwner(path, service.Value.Owner, "644");
                Console.WriteLine($"Created config {path}");
            }

            using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)))
            {
                Console.WriteLine($"Calling iotedge system set-log-level {runtimeLogLevel.ToString().ToLower()}");
                string[] output = await Process.RunAsync("iotedge", $"system set-log-level {runtimeLogLevel.ToString().ToLower()}", cts.Token);

                Console.WriteLine($"{output.ToString()}");
            }
        }