Skip to content

Commit

Permalink
TransIp DNS (#179)
Browse files Browse the repository at this point in the history
* Added NugetPackage for KeyVault keys.

* Added support for TransIp DNS provider.

* Fixed whitespace issues. Love the dotnet format tool.

* Apply suggestions from code review

Co-authored-by: Tatsuro Shibamura <[email protected]>

* Fixed PR remarks (#179): Naming conventions (Async/CamelCase), TokenCredential in constructor of provider, scopes for single line for-each.

* Update code formatting and remove unused using

* Update README

Co-authored-by: Tatsuro Shibamura <[email protected]>
  • Loading branch information
kevinvanblokland and shibayan authored Oct 27, 2020
1 parent 171befa commit 65fbcca
Show file tree
Hide file tree
Showing 6 changed files with 374 additions and 0 deletions.
1 change: 1 addition & 0 deletions KeyVault.Acmebot/KeyVault.Acmebot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<PackageReference Include="ACMESharpCore" Version="2.2.0.148" />
<PackageReference Include="Azure.Identity" Version="1.2.3" />
<PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.1.0" />
<PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.1.0" />
<PackageReference Include="DnsClient" Version="1.3.2" />
<PackageReference Include="DurableTask.TypedProxy" Version="2.1.0" />
<PackageReference Include="Google.Apis.Dns.v1" Version="1.49.0.2112" />
Expand Down
2 changes: 2 additions & 0 deletions KeyVault.Acmebot/Options/AcmebotOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ public class AcmebotOptions
public GoogleDnsOptions Google { get; set; }

public GratisDnsOptions GratisDns { get; set; }

public TransIpOptions TransIp { get; set; }
}
}
9 changes: 9 additions & 0 deletions KeyVault.Acmebot/Options/TransIpOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace KeyVault.Acmebot.Options
{
public class TransIpOptions
{
public string CustomerName { get; set; }

public string PrivateKeyName { get; set; }
}
}
356 changes: 356 additions & 0 deletions KeyVault.Acmebot/Providers/TransIpProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

using Azure.Identity;
using Azure.Security.KeyVault.Keys.Cryptography;

using KeyVault.Acmebot.Internal;
using KeyVault.Acmebot.Options;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace KeyVault.Acmebot.Providers
{
public class TransIpProvider : IDnsProvider
{
public TransIpProvider(AcmebotOptions acmeOptions, TransIpOptions options, IAzureEnvironment environment)
{
var credential = new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
AuthorityHost = new Uri(environment.ActiveDirectory)
});

var keyUri = new Uri(new Uri(acmeOptions.VaultBaseUrl), $"/keys/{options.PrivateKeyName}");
var cryptoClient = new CryptographyClient(keyUri, credential);

_transIpClient = new TransIpClient(options.CustomerName, cryptoClient);
}

private readonly TransIpClient _transIpClient;

public int PropagationSeconds => 360;

public async Task CreateTxtRecordAsync(DnsZone zone, string relativeRecordName, IEnumerable<string> values)
{
var records = values.Select(value => new DnsEntry
{
Name = relativeRecordName,
Type = "TXT",
Expire = 60,
Content = value
});

foreach (var record in records)
{
await _transIpClient.AddRecordAsync(zone.Name, record);
}
}

public async Task DeleteTxtRecordAsync(DnsZone zone, string relativeRecordName)
{
var records = await _transIpClient.ListRecordsAsync(zone.Name);

var recordsToDelete = records.Where(r => r.Name == relativeRecordName && r.Type == "TXT");

foreach (var record in recordsToDelete)
{
await _transIpClient.DeleteRecordAsync(zone.Name, record);
}
}

public async Task<IReadOnlyList<DnsZone>> ListZonesAsync()
{
var zones = await _transIpClient.ListZonesAsync();

return zones.Select(d => new DnsZone { Id = d.Name, Name = d.Name }).ToArray();
}

private class TransIpClient
{
public TransIpClient(string customerName, CryptographyClient cryptoClient)
{
_customerName = customerName;
_cryptoClient = cryptoClient;

_httpClient = new HttpClient
{
BaseAddress = new Uri("https://api.transip.nl/v6/")
};

_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}

private readonly HttpClient _httpClient;
private readonly string _customerName;
private readonly CryptographyClient _cryptoClient;

private TransIpToken _token;

public async Task<IReadOnlyList<Domain>> ListZonesAsync()
{
await EnsureLoggedInAsync();

var response = await _httpClient.GetAsync("domains");

response.EnsureSuccessStatusCode();

var domains = await response.Content.ReadAsAsync<ListDomainsResult>();

return domains.Domains;
}

public async Task<IReadOnlyList<DnsEntry>> ListRecordsAsync(string zoneName)
{
await EnsureLoggedInAsync();

var response = await _httpClient.GetAsync($"domains/{zoneName}/dns");

response.EnsureSuccessStatusCode();

var entries = await response.Content.ReadAsAsync<ListDnsEntriesResponse>();

return entries.DnsEntries;
}

public async Task DeleteRecordAsync(string zoneName, DnsEntry entry)
{
await EnsureLoggedInAsync();

var request = new DnsEntryRequest
{
DnsEntry = entry
};

var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"domains/{zoneName}/dns")
{
Content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json")
});

response.EnsureSuccessStatusCode();
}

public async Task AddRecordAsync(string zoneName, DnsEntry entry)
{
await EnsureLoggedInAsync();

var request = new DnsEntryRequest
{
DnsEntry = entry
};

var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Post, $"domains/{zoneName}/dns")
{
Content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json")
});

response.EnsureSuccessStatusCode();
}

private async Task EnsureLoggedInAsync()
{
if (_token?.IsValid() == true)
{
return;
}

if (_token is null)
{
_token = LoadToken();

if (_token?.IsValid() == true && _customerName.Equals(_token.CustomerName))
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _token.Token);

var testResponse = await _httpClient.GetAsync("api-test");

if (testResponse.IsSuccessStatusCode)
{
return;
}
}

}

await CreateNewTokenAsync();
}

private async Task CreateNewTokenAsync()
{
var nonce = new byte[16];

RandomNumberGenerator.Fill(nonce);

var request = new TokenRequest
{
Login = _customerName,
Nonce = Convert.ToBase64String(nonce)
};

(string signature, string body) = await SignRequestAsync(request);

var response = await new HttpClient().SendAsync(
new HttpRequestMessage(HttpMethod.Post, new Uri(_httpClient.BaseAddress, "auth"))
{
Headers = { { "Signature", signature } },
Content = new StringContent(body, Encoding.UTF8, "application/json")
});

response.EnsureSuccessStatusCode();

var tokenResponse = await response.Content.ReadAsAsync<TokenResponse>();

_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.Token);

_token = new TransIpToken
{
CustomerName = _customerName,
Token = tokenResponse.Token,
Expires = DateTimeOffset.FromUnixTimeSeconds(tokenResponse.GetTokenExpiration())
};

StoreToken(_token);
}

private async Task<(string token, string body)> SignRequestAsync(object request)
{
var body = JsonConvert.SerializeObject(request);

using var hasher = SHA512.Create();
var bytes = hasher.ComputeHash(Encoding.UTF8.GetBytes(body));

var signature = await _cryptoClient.SignAsync(SignatureAlgorithm.RS512, bytes);

return (Convert.ToBase64String(signature.Signature), body);
}

private void StoreToken(TransIpToken token)
{
var fullPath = Environment.ExpandEnvironmentVariables(@"%HOME%\.acme\transip_token.json");
var directoryPath = Path.GetDirectoryName(fullPath);

if (!Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}

var json = JsonConvert.SerializeObject(token, Formatting.Indented);

File.WriteAllText(fullPath, json);
}

private TransIpToken LoadToken()
{
var fullPath = Environment.ExpandEnvironmentVariables(@"%HOME%\.acme\transip_token.json");

if (!File.Exists(fullPath))
{
return null;
}

var json = File.ReadAllText(fullPath);

return JsonConvert.DeserializeObject<TransIpToken>(json);
}
}

private class TransIpToken
{
public string CustomerName { get; set; }

public string Token { get; set; }

public DateTimeOffset Expires { get; set; }

public bool IsValid()
{
return !string.IsNullOrEmpty(Token) && Expires - DateTimeOffset.Now > TimeSpan.FromMinutes(1);
}
}

private class TokenResponse
{
[JsonProperty("token")]
public string Token { get; set; }

public long GetTokenExpiration()
{
var token = Token.Split('.')[1];
token = token.PadRight(token.Length + (4 - token.Length % 4) % 4, '=');

var tokenBytes = Convert.FromBase64String(token);

var tokenObject = JObject.Parse(Encoding.UTF8.GetString(tokenBytes));

return tokenObject.Value<long>("exp");
}
}

private class TokenRequest
{
[JsonProperty("login")]
public string Login { get; set; }

[JsonProperty("nonce")]
public string Nonce { get; set; }

[JsonProperty("read_only")]
public bool ReadOnly { get; set; }

[JsonProperty("expiration_time")]
public string ExpirationTime { get; set; } = "4 weeks";

[JsonProperty("label")]
public string Label { get; set; } = "KeyVault.Acmebot." + DateTime.UtcNow;

[JsonProperty("global_key")]
public bool GlobalKey { get; set; } = true;
}

private class ListDomainsResult
{
[JsonProperty("domains")]
public IReadOnlyList<Domain> Domains { get; set; }
}

private class Domain
{
[JsonProperty("name")]
public string Name { get; set; }
}

private class ListDnsEntriesResponse
{
[JsonProperty("dnsEntries")]
public IReadOnlyList<DnsEntry> DnsEntries { get; set; }
}

private class DnsEntryRequest
{
[JsonProperty("dnsEntry")]
public DnsEntry DnsEntry { get; set; }
}

private class DnsEntry
{
[JsonProperty("name")]
public string Name { get; set; }

[JsonProperty("expire")]
public int Expire { get; set; }

[JsonProperty("type")]
public string Type { get; set; }

[JsonProperty("content")]
public string Content { get; set; }
}
}
}
5 changes: 5 additions & 0 deletions KeyVault.Acmebot/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ public override void Configure(IFunctionsHostBuilder builder)
return new GratisDnsProvider(options.GratisDns);
}

if (options.TransIp != null)
{
return new TransIpProvider(options, options.TransIp, environment);
}

if (options.AzureDns != null)
{
return new AzureDnsProvider(options.AzureDns, environment);
Expand Down
Loading

0 comments on commit 65fbcca

Please sign in to comment.