smoke/IotEdgeQuickstart/details/IotedgedLinux.cs (569 lines of code) (raw):
// Copyright (c) Microsoft. All rights reserved.
namespace IotEdgeQuickstart.Details
{
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Azure.Devices.Edge.Test.Common;
using Microsoft.Azure.Devices.Edge.Util;
public class HttpUris
{
const int ManagementPort = 15580;
const int WorkloadPort = 15581;
public HttpUris()
: this(GetIpAddress())
{
}
public HttpUris(string hostname)
{
this.ConnectManagement = $"http://{hostname}:{ManagementPort}";
this.ConnectWorkload = $"http://{hostname}:{WorkloadPort}";
this.ListenManagement = $"http://0.0.0.0:{ManagementPort}";
this.ListenWorkload = $"http://0.0.0.0:{WorkloadPort}";
}
public string ConnectManagement { get; }
public string ConnectWorkload { get; }
public string ListenManagement { get; }
public string ListenWorkload { get; }
static string GetIpAddress()
{
// TODO: should use an internal IP address--e.g. docker0's address--instead
// of the public-facing address. The output of this command would be
// a good candidate:
// docker network inspect --format='{{(index .IPAM.Config 0).Gateway}}' bridge
const string Server = "microsoft.com";
const int Port = 443;
IPHostEntry entry = Dns.GetHostEntry(Server);
foreach (IPAddress address in entry.AddressList)
{
var endpoint = new IPEndPoint(address, Port);
using (var s = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp))
{
s.Connect(endpoint);
if (s.Connected)
{
return (s.LocalEndPoint as IPEndPoint)?.Address.ToString();
}
}
}
return string.Empty;
}
}
interface ILinuxPackageInstall
{
public Task Install();
public Task FindPackage(string packageName);
public Task RemovePackage(string packageName);
}
class LinuxPackageInstallDep : ILinuxPackageInstall
{
const string PackageManager = "apt";
const int TimeoutInterval = 300;
readonly string archivePath;
public LinuxPackageInstallDep(string archivePath)
{
if (!Directory.Exists(archivePath))
{
throw new ArgumentException("archive path must exist");
}
this.archivePath = archivePath;
}
public Task Install()
{
string[] packages = Directory.GetFiles(this.archivePath, "*.deb");
foreach (string package in packages)
{
Console.WriteLine($"Will install {package}");
}
string packageArguments = string.Join(" ", packages);
return Process.RunAsync(
PackageManager,
$"install -y {packageArguments}",
TimeoutInterval); // 5 min timeout because install can be slow on raspberry pi
}
public Task FindPackage(string packageName)
{
return Process.RunAsync("bash", $"-c \"dpkg -l | grep {packageName}\"");
}
public Task RemovePackage(string packageName)
{
return Process.RunAsync(PackageManager, $"purge -y {packageName}", TimeoutInterval);
}
}
class LinuxPackageInstallRPM : ILinuxPackageInstall
{
const string PackageManager = "dnf";
const int TimeoutInterval = 300;
readonly string archivePath;
public LinuxPackageInstallRPM(string archivePath)
{
if (!Directory.Exists(archivePath))
{
throw new ArgumentException("archive path must exist");
}
this.archivePath = archivePath;
}
public Task Install()
{
string[] packages = Directory.GetFiles(this.archivePath, "*.rpm");
return Process.RunAsync(
PackageManager,
$"-y install {string.Join(' ', packages)}",
TimeoutInterval);
}
public Task FindPackage(string packageName)
{
return Process.RunAsync("bash", $"-c \"rpm -qa | grep {packageName}\"");
}
public Task RemovePackage(string packageName)
{
return Process.RunAsync(PackageManager, $"remove -y {packageName}", TimeoutInterval);
}
}
class LinuxPackageNonInstall : ILinuxPackageInstall
{
public Task Install()
{
Console.WriteLine("Skipping installation of aziot-edge and aziot-identity-service.");
return Task.CompletedTask;
}
public Task FindPackage(string packageName)
{
throw new Exception("Find package not permitted for non-installed packages");
}
public Task RemovePackage(string packageName)
{
throw new Exception("remove package not permitted for non-installed packages");
}
}
class IotedgedLinux : IBootstrapper
{
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.toml";
readonly Option<RegistryCredentials> credentials;
readonly Option<HttpUris> httpUris;
readonly UriSocks uriSocks;
readonly Option<string> proxy;
readonly Option<UpstreamProtocolType> upstreamProtocol;
readonly bool overwritePackages;
ILinuxPackageInstall installCommands;
private struct Config
{
public string Owner;
public string PrincipalsPath;
public uint Uid;
public TomlDocument Document;
}
public IotedgedLinux(Option<RegistryCredentials> credentials, Option<HttpUris> httpUris, UriSocks uriSocks, Option<string> proxy, Option<UpstreamProtocolType> upstreamProtocol, bool overwritePackages, ILinuxPackageInstall installCommands)
{
this.credentials = credentials;
this.httpUris = httpUris;
this.uriSocks = uriSocks;
this.proxy = proxy;
this.upstreamProtocol = upstreamProtocol;
this.overwritePackages = overwritePackages;
this.installCommands = installCommands;
}
public async Task UpdatePackageState()
{
string[] packages = new string[] { "aziot-edge", "aziot-identity-service", "iotedge", "libiothsm-std" };
foreach (string package in packages)
{
try
{
await this.installCommands.FindPackage(package);
if (this.overwritePackages)
{
Console.WriteLine($"{package}: found. Removing package.");
await this.installCommands.RemovePackage(package);
}
else
{
throw new Exception($"{package}: found. Not overwriting existing packages.");
}
}
catch (Win32Exception)
{
Console.WriteLine($"{package}: not found.");
}
}
}
public Task VerifyDependenciesAreInstalled() => Task.CompletedTask;
public async Task VerifyModuleIsRunning(string name)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(20))) // This long timeout is needed for resource constrained devices pulling the large tempFilterFunctions image
{
string errorMessage = null;
try
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(3), cts.Token);
string options = this.httpUris.Match(uris => $"-H {uris.ConnectManagement} ", () => string.Empty);
try
{
string[] result = await Process.RunAsync(
"iotedge",
$"{options}list",
cts.Token);
string status = result
.Where(ln => ln.Split(null as char[], StringSplitOptions.RemoveEmptyEntries).First() == name)
.DefaultIfEmpty("name status")
.Single()
.Split(null as char[], StringSplitOptions.RemoveEmptyEntries)
.ElementAt(1); // second column is STATUS
if (status == "running")
{
break;
}
errorMessage = "Not found";
}
catch (Win32Exception e)
{
Console.WriteLine($"Error searching for {name} module: {e.Message}. Retrying.");
}
}
}
catch (OperationCanceledException e)
{
throw new Exception($"Error searching for {name} module: {errorMessage ?? e.Message}");
}
catch (Exception e)
{
throw new Exception($"Error searching for {name} module: {e.Message}");
}
}
}
public Task Install()
{
return this.installCommands.Install();
}
private static async Task<Config> InitConfig(string template, string owner)
{
Config config;
string text = File.ReadAllText(template);
config.Document = new TomlDocument(text);
string principalsPath = Path.Combine(
Path.GetDirectoryName(template),
"config.d");
if (Directory.Exists(principalsPath))
{
Directory.Delete(principalsPath, true);
Directory.CreateDirectory(principalsPath);
SetOwner(principalsPath, owner, "755");
Console.WriteLine($"Cleared {principalsPath}");
}
config.PrincipalsPath = principalsPath;
config.Owner = owner;
config.Uid = await GetUid(owner);
return config;
}
private void SetAuth(string keyName, Dictionary<string, Config> config)
{
// Grant Identity Service access to the provided device-id key and its master encryption key.
this.AddAuthPrincipal(
Path.Combine(config[KEYD].PrincipalsPath, "aziot-identityd-principal.toml"),
config[KEYD].Owner,
config[IDENTITYD].Uid,
new string[] { keyName, "aziot_identityd_master_id" });
// Grant aziot-edged access to device CA certs, server certs, and its master encryption key.
this.AddIdentityPrincipal("aziot-edged", config[EDGED].Uid);
this.AddAuthPrincipal(
Path.Combine(config[KEYD].PrincipalsPath, "aziot-edged-principal.toml"),
config[KEYD].Owner,
config[EDGED].Uid,
new string[] { "iotedge_master_encryption_id", "aziot-edged-ca" });
this.AddAuthPrincipal(
Path.Combine(config[CERTD].PrincipalsPath, "aziot-edged-principal.toml"),
config[CERTD].Owner,
config[EDGED].Uid,
new string[] { "aziot-edged/module/*" });
}
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()}");
}
}
public async Task Start()
{
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)))
{
await Process.RunAsync("iotedge", "system restart", cts.Token);
Console.WriteLine("Waiting for aziot-edged to start up.");
// Waiting for the processes to enter the "Running" state doesn't guarantee that
// they are fully started and ready to accept requests. Therefore, this function
// must wait until a request can be processed.
while (true)
{
var processInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "iotedge",
Arguments = "list",
RedirectStandardOutput = true
};
var request = System.Diagnostics.Process.Start(processInfo);
if (request.WaitForExit(1000))
{
if (request.ExitCode == 0)
{
request.Close();
Console.WriteLine("aziot-edged ready for requests.");
break;
}
}
else
{
request.Kill(true);
request.WaitForExit();
request.Close();
Console.WriteLine("aziot-edged not yet ready.");
}
}
}
}
public async Task Stop()
{
await Process.RunAsync("systemctl", "stop aziot-edged", 60);
await Process.RunAsync("systemctl", "stop aziot-identityd", 60);
await Process.RunAsync("systemctl", "stop aziot-tpmd", 60);
await Process.RunAsync("systemctl", "stop aziot-certd", 60);
await Process.RunAsync("systemctl", "stop aziot-keyd", 60);
}
public Task Reset() => Task.CompletedTask;
private static async Task<uint> GetUid(string user)
{
string[] output = await Process.RunAsync("id", $"-u {user}");
string uid = output[0].Trim();
return System.Convert.ToUInt32(uid, 10);
}
private static void SetOwner(string path, string owner, string permissions)
{
var chown = System.Diagnostics.Process.Start("chown", $"{owner}:{owner} {path}");
chown.WaitForExit();
chown.Close();
var chmod = System.Diagnostics.Process.Start("chmod", $"{permissions} {path}");
chmod.WaitForExit();
chmod.Close();
}
private void AddIdentityPrincipal(string name, uint uid, string[] type = null, Dictionary<string, string> opts = null)
{
string path = $"/etc/aziot/identityd/config.d/{name}-principal.toml";
string principal = string.Join(
"\n",
"[[principal]]",
$"uid = {uid}",
$"name = \"{name}\"");
if (type != null)
{
// Need to quote each type.
for (int i = 0; i < type.Length; i++)
{
type[i] = $"\"{type[i]}\"";
}
string types = string.Join(", ", type);
principal = string.Join("\n", principal, $"idtype = [{types}]");
}
if (opts != null)
{
foreach (KeyValuePair<string, string> opt in opts)
{
principal = string.Join("\n", principal, $"{opt.Key} = {opt.Value}");
}
}
File.WriteAllText(path, principal + "\n");
SetOwner(path, "aziotid", "644");
}
private void AddAuthPrincipal(string path, string owner, uint uid, string[] credentials)
{
if (credentials == null || credentials.Length == 0)
{
throw new ArgumentException("Empty array of credentials");
}
string auth = string.Empty;
if (path.Contains("keyd"))
{
auth += "keys = [";
}
else if (path.Contains("certd"))
{
auth += "certs = [";
}
else
{
throw new ArgumentException("Invalid path for auth principal");
}
for (int i = 0; i < credentials.Length; i++)
{
credentials[i] = $"\"{credentials[i]}\"";
}
auth += string.Join(", ", credentials);
auth += "]";
string principal = string.Join(
"\n",
"[[principal]]",
$"uid = {uid}",
auth);
File.WriteAllText(path, principal + "\n");
SetOwner(path, owner, "644");
}
}
}