Skip to content

Instantly share code, notes, and snippets.

@chgeuer
Last active July 5, 2023 13:28
Show Gist options
  • Save chgeuer/08bfe69578cd485fc51db1b45426aa5e to your computer and use it in GitHub Desktop.
Save chgeuer/08bfe69578cd485fc51db1b45426aa5e to your computer and use it in GitHub Desktop.
namespace Downloader
{
using System;
using System.IO;
using System.IO.Compression;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using Azure.Core;
using Azure.Identity; // <PackageReference Include="Azure.Identity" Version="1.9.0" />
public record PublishingCredentialProperties(string PublishingUserName, string PublishingPassword, string ScmUri);
public record PublishingCredential(string Id, string Name, PublishingCredentialProperties Properties);
public record VirtualFileSystemEntry(string Name, long Size, DateTimeOffset Mtime, DateTimeOffset Crtime, string Mime, string Href, string Path);
internal record SiteInfo(string TenantID, string SubscriptionID, string ResourceGroupName, string SiteName, string SlotName);
internal enum FollowPolicy { IgnoreShortcuts = 0, FollowShortcuts = 1 }
internal enum SCMAuthenticationMechanism { UseSCMApplicationScope = 0, UseAccessToken = 1}
public static class DownloadFunctionAppContents
{
public static async Task Main()
{
var siteName = "chgeuer1";
var authMechanism = SCMAuthenticationMechanism.UseSCMApplicationScope;
SiteInfo ISVSite(string ResourceGroupName, string SiteName) => new("geuer-pollmann.de", "706df49f-998b-40ec-aed3-7f0ce9c67759", ResourceGroupName, SiteName, SlotName: null);
SiteInfo CustomerSite(string ResourceGroupName, string SiteName) => new("chgeuerfte.aad.geuer-pollmann.de", "724467b5-bee4-484b-bf13-d6a5505d2b51", ResourceGroupName, SiteName, SlotName: null);
List<SiteInfo> siteInfos = new()
{
ISVSite("meteredbilling-infra-20230112", "spqpzpz3chwpnb6"),
CustomerSite("checkpoint", "chgeuer1"),
};
SiteInfo siteInfo = siteInfos.Single(si => si.SiteName == siteName);
var (clientId, clientSecretFile) = ("7b8e9825-af72-4c2a-a2df-94929355b3b8", @"C:\Users\chgeuer\.secrets\principal-for-unencrypted-function-scanning.txt");
// https://learn.microsoft.com/en-us/dotnet/api/overview/azure/identity-readme?view=azure-dotnet
// DefaultAzureCredential cred = new();
// AzureCliCredential cred = new();
ClientSecretCredential cred = new(
tenantId: siteInfo.TenantID,
clientId: clientId,
clientSecret: await File.ReadAllTextAsync(path: clientSecretFile));
var accessToken = await cred.GetTokenAsync(new(scopes: new[] { "https://management.azure.com/.default" }));
HttpClient armHttpClient = accessToken.CreateARMHttpClient();
bool needToDisableSCMBasicAuthAgain = false;
if (authMechanism == SCMAuthenticationMechanism.UseSCMApplicationScope)
{
bool scmBasicAuthAllowed = await armHttpClient.GetSCMBasicAuthAllowed(siteInfo);
if (!scmBasicAuthAllowed)
{
await armHttpClient.SetSCMSetBasicAuth(siteInfo, allow: true);
// Wait until the updated permission propagated to the SCM site.
await Task.Delay(TimeSpan.FromSeconds(2));
needToDisableSCMBasicAuthAgain = true;
}
}
var vfsEndpoint = $"https://{siteInfo.SiteName}.scm.azurewebsites.net/api/vfs/";
var zipFilename = new FileInfo($"{siteInfo.SiteName}.zip").FullName;
using var outputStream = File.OpenWrite(zipFilename);
using ZipArchive zipArchive = new(outputStream, ZipArchiveMode.Create);
try
{
HttpClient scmClient = authMechanism switch
{
SCMAuthenticationMechanism.UseSCMApplicationScope => (await armHttpClient.FetchSCMCredential(siteInfo)).CreateSCMHttpClient(),
SCMAuthenticationMechanism.UseAccessToken => accessToken.CreateSCMHttpClient(),
_ => throw new NotSupportedException(),
};
await scmClient.RecurseAsync(
requestUri: vfsEndpoint,
policy: FollowPolicy.IgnoreShortcuts,
task: zipArchive.CreateEntry);
await Console.Out.WriteLineAsync($"Created archive {zipFilename}");
}
finally
{
if (needToDisableSCMBasicAuthAgain)
{
await armHttpClient.SetSCMSetBasicAuth(siteInfo, allow: false);
}
}
}
static async Task CreateEntry(this ZipArchive zipArchive, HttpClient client, VirtualFileSystemEntry vfsEntry)
{
await Console.Out.WriteLineAsync($"Adding {vfsEntry.Path}");
try
{
var zipArchiveEntry = zipArchive.CreateEntry(
entryName: vfsEntry.Path.Replace(@"C:\", ""),
compressionLevel: CompressionLevel.Optimal);
zipArchiveEntry.LastWriteTime = vfsEntry.Crtime;
using var zipArchiveEntryStream = zipArchiveEntry.Open();
var downloadStream = await client.GetStreamAsync(requestUri: vfsEntry.Href);
await downloadStream.CopyToAsync(zipArchiveEntryStream);
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"{vfsEntry.Href} {vfsEntry.Name} {ex.Message}");
}
}
internal static string CreateURL(this SiteInfo info, string suffix)
=> string.IsNullOrEmpty(info.SlotName)
? $"/subscriptions/{info.SubscriptionID}/resourceGroups/{info.ResourceGroupName}/providers/Microsoft.Web/sites/{info.SiteName}/{suffix}"
: $"/subscriptions/{info.SubscriptionID}/resourceGroups/{info.ResourceGroupName}/providers/Microsoft.Web/sites/{info.SiteName}/slots/{info.SlotName}/{suffix}";
internal static HttpClient AddAccessTokenCredential(this HttpClient httpClient, AccessToken accessToken)
{
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken.Token}");
return httpClient;
}
internal static HttpClient AddBasicAuthCredential(this HttpClient httpClient, string username, string password)
{
httpClient.DefaultRequestHeaders.Add("Authorization", $"Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"), Base64FormattingOptions.None)}");
return httpClient;
}
internal static HttpClient AddAccessTokenAsBasicAuthCredential(this HttpClient httpClient, AccessToken accessToken)
=> httpClient.AddBasicAuthCredential(username: "00000000-0000-0000-0000-000000000000", password: accessToken.Token);
internal static HttpClient CreateARMHttpClient(this AccessToken accessToken)
=> new HttpClient() { BaseAddress = new("https://management.azure.com/") }.AddAccessTokenCredential(accessToken);
internal static HttpClient CreateSCMHttpClient(this AccessToken accessToken)
=> new HttpClient().AddAccessTokenAsBasicAuthCredential(accessToken);
internal static HttpClient CreateSCMHttpClient(this PublishingCredential cred)
=> new HttpClient().AddBasicAuthCredential(
username: cred.Properties.PublishingUserName,
password: cred.Properties.PublishingPassword);
internal static async Task<PublishingCredential> FetchSCMCredential(this HttpClient armHttpClient, SiteInfo info)
{
// Requires action 'Microsoft.Web/sites/config/list/action' (Other : List Web App Security Sensitive Settings: List Web App's security sensitive settings, such as publishing credentials, app settings and connection strings)
var requestUri = info.CreateURL("config/publishingcredentials/list?api-version=2022-09-01");
var scmCredentialResponse = await armHttpClient.PostAsync(requestUri, content: null);
scmCredentialResponse.EnsureSuccessStatusCode();
return await scmCredentialResponse.Content.ReadFromJsonAsync<PublishingCredential>();
}
internal static async Task<bool> GetSCMBasicAuthAllowed(this HttpClient armHttpClient, SiteInfo info)
{
// Requires action 'Microsoft.Web/sites/basicPublishingCredentialsPolicies/read'
var requestUri = info.CreateURL("basicPublishingCredentialsPolicies/scm?api-version=2022-09-01");
var policyJsonStr = await armHttpClient.GetStringAsync(requestUri);
JsonNode json = JsonNode.Parse(policyJsonStr)!;
return (bool)json["properties"]["allow"];
}
internal static async Task SetSCMSetBasicAuth(this HttpClient armHttpClient, SiteInfo info, bool allow)
{
// az resource update \
// --subscription "${subscriptionId}" --resource-group "${resourceGroupName}" \
// --namespace Microsoft.Web --parent "sites/${siteName}" \
// --resource-type basicPublishingCredentialsPolicies --name scm --set properties.allow=true
var requestUri = info.CreateURL("basicPublishingCredentialsPolicies/scm?api-version=2022-09-01");
// Requires action 'Microsoft.Web/sites/basicPublishingCredentialsPolicies/scm/Read' and 'Microsoft.Web/sites/slots/basicPublishingCredentialsPolicies/scm/Read'
var policyJsonStr = await armHttpClient.GetStringAsync(requestUri);
JsonNode json = JsonNode.Parse(policyJsonStr)!;
if (allow != (bool)json["properties"]["allow"])
{
json["properties"]["allow"] = allow;
// Requires 'Microsoft.Web/sites/basicPublishingCredentialsPolicies/write'
await armHttpClient.PutAsync(requestUri, new StringContent(
content: json.ToJsonString(),
encoding: Encoding.UTF8,
mediaType: "application/json"));
}
}
internal static async Task RecurseAsync(this HttpClient scmHttpClient, string requestUri, FollowPolicy policy, Func<HttpClient, VirtualFileSystemEntry, Task> task)
{
var response = await scmHttpClient.GetAsync(requestUri);
if (response == null || response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
await Console.Error.WriteLineAsync($"Not found: {requestUri}");
return;
}
response.EnsureSuccessStatusCode();
var entries = await response.Content.ReadFromJsonAsync<IEnumerable<VirtualFileSystemEntry>>();
foreach (var entry in entries)
{
await ((entry.Mime, policy) switch
{
("inode/directory", _) => scmHttpClient.RecurseAsync(entry.Href, policy, task),
("inode/shortcut", FollowPolicy.FollowShortcuts) => scmHttpClient.RecurseAsync(entry.Href, policy, task),
("inode/shortcut", FollowPolicy.IgnoreShortcuts) => Task.CompletedTask,
_ => task(scmHttpClient, entry)
});
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment