Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update #1

Merged
merged 14 commits into from
Apr 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
uses: actions/setup-dotnet@v3
with:
dotnet-version: 8.0.x
- name: Install .NET Aspire workload
run: dotnet workload install aspire
- name: Restore dependencies
run: dotnet restore ./src/wan24-AutoDiscover.sln --ignore-failed-sources
- name: Build lib
Expand Down
159 changes: 154 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ Exchange POX autodiscover standard, which allows email clients to receive
automatic configuration information for an email account.

It was created using .NET 8 and ASP.NET. You find a published release build
for each published release on GitHub as ZIP download for self-hosting.
for each published release on GitHub as ZIP file download for self-hosting.

The webservice is designed for working with dynamic MTA configurations and
tries to concentrate on the basics for fast request handling and response. All
required informations will be held in memory, so no database or filesystem
access is required for request handling.

## Usage

Expand All @@ -20,9 +25,9 @@ For example on a Debian Linux server:
```bash
mkdir /home/autodiscover
cd /home/autodiscover
wget https://github.com/nd1012/wan24-AutoDiscover/releases/download/v1.0.0/wan24-AutoDiscover.v1.0.0.zip
unzip wan24-AutoDiscover.v1.0.0.zip
rm wan24-AutoDiscover.v1.0.0.zip
wget https://github.com/nd1012/wan24-AutoDiscover/releases/download/v1.1.0/wan24-AutoDiscover.v1.1.0.zip
unzip wan24-AutoDiscover.v1.1.0.zip
rm wan24-AutoDiscover.v1.1.0.zip
```

### `appsettings.json`
Expand Down Expand Up @@ -103,7 +108,7 @@ For serving a request, the `DomainConfig` will be looked up

Any unmatched `DomainConfig` will cause a `Bad request` http response.

Documentation references:
Find the online documentation of the used types here:

- [`DiscoveryConfig`](https://nd1012.github.io/wan24-AutoDiscover/api/wan24.AutoDiscover.Models.DiscoveryConfig.html)
- [`DomainConfig`](https://nd1012.github.io/wan24-AutoDiscover/api/wan24.AutoDiscover.Models.DomainConfig.html)
Expand Down Expand Up @@ -217,3 +222,147 @@ things for you:
```bash
dotnet wan24AutoDiscover.dll autodiscover systemd > /etc/systemd/system/autodiscover.service
```

#### Parse a Postfix virtual configuration file

```bash
dotnet wan24AutoDiscover.dll autodiscover postfix < /etc/postfix/virtual > /home/autodiscover/postfix.json
```

#### Display version number

```bash
dotnet wan24AutoDiscover.dll autodiscover version
```

#### Upgrade online

Check for an available newer version only:

```bash
dotnet wan24AutoDiscover.dll autodiscover upgrade -checkOnly
```

**NOTE**: The command will exit with code #2, if an update is available online.

Upgrade with user interaction:

```bash
dotnet wan24AutoDiscover.dll autodiscover upgrade
```

Upgrade without user interaction:

```bash
dotnet wan24AutoDiscover.dll autodiscover upgrade -noUserInteraction
```

#### Display detailed CLI API usage instructions

```bash
dotnet wan24AutoDiscover.dll help -details
```

## Login name mapping

If the login name isn't the email address or the alias of the given email
address, you can create a login name mapping per domain and/or protocol, by
defining a mapping from the email address or alias to the login name. During
lookup the protocol mapping and then the domain mapping will be used by trying
the email address and then the alias as key.

### Automatic email address to login user mapping with Postfix

If your Postfix virtual email mappings are stored in a hash text file, you can
create an email mapping from is using

```bash
dotnet wan24AutoDiscover.dll autodiscover postfix < /etc/postfix/virtual > /home/autodiscover/postfix.json
```

Then you can add the `postix.json` to your `appsettings.json`:

```json
{
...
"DiscoveryConfig": {
...
"EmailMappings": "/home/autodiscover/postfix.json",
...
}
}
```

The configuration will be reloaded, if the `postfix.json` file changed, so be
sure to re-create the `postfix.json` file as soon as the `virtual` file was
changed. If you don't want that, set `WatchEmailMappings` to `false`.

### Additionally watched files

You can set a list of additionally watched file paths to `WatchFiles` in your
`appsettings.json` file. When any file was changed, the configuration will be
reloaded.

### Pre-reload command execution

To execute a command before reloading a changed configration, set the
`PreReloadCommand` value in your `appsettings.json` like this:

```json
{
...
"DiscoveryConfig": {
...
"PreReloadCommand": ["/command/to/execute", "argument1", "argument2", ...],
...
}
}
```

## Automatic online upgrades

You can upgrade `wan24-AutoDiscover` online and automatic. For this some steps
are recommended:

1. Create sheduled task for auto-upgrade (daily, for example)
1. Stop the service before installing the newer version
1. Start the service after installing the newer version

The sheduled auto-upgrade task should execute this command on a Debian Linux
server, for example:

```bash
dotnet /home/autodiscover/wan24AutoDiscover.dll autodiscover upgrade -noUserInteraction --preCommand systemctl stop autodiscover --postCommand systemctl start autodiscover
```

If the upgrade download failed, nothing will happen - the upgrade won't be
installed only and being re-tried at the next sheduled auto-upgrade time.

If the upgrade installation failed, the post-upgrade command won't be
executed, and the autodiscover service won't run. This'll give you the chance
to investigate the broken upgrade and optional restore the running version
manually.

**CAUTION**: The auto-upgrade is being performed using the GitHub repository.
There are no security checks at present - so if the repository was hacked, you
could end up with upgrading to a compromised software which could harm your
system!

The upgrade setup should be done in less than a second, if everything was fine.

## Manual upgrade

1. Download the latest release ZIP file from GitHub
1. Extract the ZIP file to a temporary folder
1. Stop the autodiscover service, if running
1. Create a backup of your current installation
1. Copy all extracted files/folders excluding `appsettings.json` to your
installation folder
1. Remove files/folders that are no longer required and perform additional
upgrade steps, which are required for the new release (see upgrade
instructions)
1. Start the autodiscover service
1. Delete the previously created backup and the temporary folder

These steps are being executed during an automatic upgrade like described
above also.
1 change: 1 addition & 0 deletions latest-release.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.1.0
32 changes: 32 additions & 0 deletions src/wan24-AutoDiscover Shared/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,41 @@
/// </summary>
public static class Constants
{
/// <summary>
/// Auto discovery XML namespace
/// </summary>
public const string AUTO_DISCOVER_NS = "http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006";
/// <summary>
/// POX response node XML namespace
/// </summary>
public const string RESPONSE_NS = "https://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
/// <summary>
/// <c>Autodiscover</c> node name
/// </summary>
public const string AUTODISCOVER_NODE_NAME = "Autodiscover";
/// <summary>
/// <c>Response</c> node name
/// </summary>
public const string RESPONSE_NODE_NAME = "Response";
/// <summary>
/// <c>Account</c> node name
/// </summary>
public const string ACCOUNT_NODE_NAME = "Account";
/// <summary>
/// <c>AccountType</c> node name
/// </summary>
public const string ACCOUNTTYPE_NODE_NAME = "AccountType";
/// <summary>
/// Account type
/// </summary>
public const string ACCOUNTTYPE = "email";
/// <summary>
/// <c>Action</c> node name
/// </summary>
public const string ACTION_NODE_NAME = "Action";
/// <summary>
/// Action
/// </summary>
public const string ACTION = "settings";
}
}
98 changes: 89 additions & 9 deletions src/wan24-AutoDiscover Shared/Models/DiscoveryConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Frozen;
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mail;
using System.Text.Json.Serialization;
using wan24.Core;
using wan24.ObjectValidation;
Expand All @@ -12,12 +13,22 @@ namespace wan24.AutoDiscover.Models
/// <summary>
/// Discovery configuration
/// </summary>
public record class DiscoveryConfig
public record class DiscoveryConfig : ValidatableRecordBase
{
/// <summary>
/// Discovery configuration type
/// </summary>
protected Type? _DiscoveryType = null;

/// <summary>
/// Constructor
/// </summary>
public DiscoveryConfig() { }
public DiscoveryConfig() : base() { }

/// <summary>
/// Current configuration
/// </summary>
public static DiscoveryConfig Current { get; set; } = null!;

/// <summary>
/// Logfile path
Expand All @@ -41,22 +52,46 @@ public DiscoveryConfig() { }
/// Discovery configuration type
/// </summary>
[JsonIgnore]
public Type DiscoveryType => DiscoveryTypeName is null
public virtual Type DiscoveryType => _DiscoveryType ??= string.IsNullOrWhiteSpace(DiscoveryTypeName)
? typeof(Dictionary<string, DomainConfig>)
: TypeHelper.Instance.GetType(DiscoveryTypeName)
?? throw new InvalidDataException($"Discovery type {DiscoveryTypeName.ToQuotedLiteral()} not found");

/// <summary>
/// Known http proxies
/// </summary>
public HashSet<IPAddress> KnownProxies { get; init; } = [];
public IReadOnlySet<IPAddress> KnownProxies { get; init; } = new HashSet<IPAddress>();

/// <summary>
/// JSON file path which contains the email mappings list
/// </summary>
[StringLength(short.MaxValue, MinimumLength = 1)]
public string? EmailMappings { get; init; }

/// <summary>
/// Watch email mappings list file changes for reloading the configuration?
/// </summary>
public bool WatchEmailMappings { get; init; } = true;

/// <summary>
/// Additional file paths to watch for an automatic configuration reload
/// </summary>
[CountLimit(1, byte.MaxValue), ItemStringLength(short.MaxValue)]
public string[]? WatchFiles { get; init; }

/// <summary>
/// Command to execute (and optional arguments) before reloading the configuration
/// </summary>
[CountLimit(1, byte.MaxValue), ItemStringLength(short.MaxValue)]
public string[]? PreReloadCommand { get; init; }

/// <summary>
/// Get the discovery configuration
/// </summary>
/// <param name="config">Configuration</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Discovery configuration</returns>
public FrozenDictionary<string, DomainConfig> GetDiscoveryConfig(IConfigurationRoot config)
public virtual async Task<IReadOnlyDictionary<string, DomainConfig>> GetDiscoveryConfigAsync(IConfigurationRoot config, CancellationToken cancellationToken = default)
{
Type discoveryType = DiscoveryType;
if (!typeof(IDictionary).IsAssignableFrom(discoveryType))
Expand All @@ -68,19 +103,64 @@ public FrozenDictionary<string, DomainConfig> GetDiscoveryConfig(IConfigurationR
if (gt.Length != 2)
throw new InvalidDataException($"Discovery type must be a generic type with two type arguments");
if (gt[0] != typeof(string))
throw new InvalidDataException($"Discovery types first generic type argument must be a {typeof(string)}");
throw new InvalidDataException($"Discovery types first generic type argument must be {typeof(string)}");
if (!typeof(DomainConfig).IsAssignableFrom(gt[1]))
throw new InvalidDataException($"Discovery types second generic type argument must be a {typeof(DomainConfig)}");
// Parse the discovery configuration
IDictionary discovery = config.GetRequiredSection("DiscoveryConfig:Discovery").Get(discoveryType) as IDictionary
?? throw new InvalidDataException("Failed to get discovery configuration");
?? throw new InvalidDataException("Failed to get discovery configuration from the \"DiscoveryConfig:Discovery section\"");
object[] keys = new object[discovery.Count],
values = new object[discovery.Count];
discovery.Keys.CopyTo(keys, index: 0);
discovery.Values.CopyTo(values, index: 0);
return new Dictionary<string, DomainConfig>(
Dictionary<string, DomainConfig> discoveryDomains = new(
Enumerable.Range(0, discovery.Count).Select(i => new KeyValuePair<string, DomainConfig>((string)keys[i], (DomainConfig)values[i]))
).ToFrozenDictionary();
);
// Apply email mappings
if (!string.IsNullOrWhiteSpace(EmailMappings))
if (File.Exists(EmailMappings))
{
Logging.WriteInfo($"Loading email mappings from {EmailMappings.ToQuotedLiteral()}");
FileStream fs = FsHelper.CreateFileStream(EmailMappings, FileMode.Open, FileAccess.Read, FileShare.Read);
EmailMapping[] mappings;
await using (fs.DynamicContext())
mappings = await JsonHelper.DecodeAsync<EmailMapping[]>(fs, cancellationToken).DynamicContext()
?? throw new InvalidDataException("Invalid email mappings");
foreach(EmailMapping mapping in mappings)
{
if (!mapping.Email.Contains('@'))
{
if (Logging.Debug)
Logging.WriteDebug($"Skipping invalid email address {mapping.Email.ToQuotedLiteral()}");
continue;
}
string email = mapping.Email.ToLower();
string[] emailParts = email.Split('@', 2);
if (
emailParts.Length != 2 ||
!MailAddress.TryCreate(email, out MailAddress? emailAddress) ||
(emailAddress.User.Length == 1 && (emailAddress.User[0] == '*' || emailAddress.User[0] == '@')) ||
EmailMapping.GetLoginUser(mappings, email) is not string loginUser ||
DomainConfig.GetConfig(string.Empty, emailParts) is not DomainConfig domain
)
{
if (Logging.Debug)
Logging.WriteDebug($"Mapping email address {email.ToQuotedLiteral()} to login user failed, because it seems to be a redirection to an external target, or no matching domain configuration was found");
continue;
}
if (Logging.Debug)
Logging.WriteDebug($"Mapping email address {email.ToQuotedLiteral()} to login user {loginUser.ToQuotedLiteral()}");
domain.LoginNameMapping ??= [];
if (Logging.Debug && domain.LoginNameMapping.ContainsKey(email))
Logging.WriteDebug($"Overwriting existing email address {email.ToQuotedLiteral()} mapping");
domain.LoginNameMapping[email] = loginUser;
}
}
else
{
Logging.WriteWarning($"Email mappings file {EmailMappings.ToQuotedLiteral()} not found");
}
return discoveryDomains.ToFrozenDictionary();
}
}
}
Loading
Loading