Skip to content

Commit

Permalink
Escl: HTTPS support, security policies, and HTTPS->HTTP fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
cyanfish committed Mar 28, 2024
1 parent 79bba70 commit 05bd7d9
Show file tree
Hide file tree
Showing 34 changed files with 675 additions and 114 deletions.
16 changes: 13 additions & 3 deletions NAPS2.Escl.Server/EsclApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@ internal class EsclApiController : WebApiController

private readonly EsclDeviceConfig _deviceConfig;
private readonly EsclServerState _serverState;
private readonly EsclSecurityPolicy _securityPolicy;
private readonly ILogger _logger;

internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, ILogger logger)
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState,
EsclSecurityPolicy securityPolicy, ILogger logger)
{
_deviceConfig = deviceConfig;
_serverState = serverState;
_securityPolicy = securityPolicy;
_logger = logger;
}

[Route(HttpVerbs.Get, "/ScannerCapabilities")]
public async Task GetScannerCapabilities()
{
var caps = _deviceConfig.Capabilities;
var iconUri = caps.IconPng != null ? $"http://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : "";
var protocol = _securityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) ? "https" : "http";
var iconUri = caps.IconPng != null ? $"{protocol}://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : "";
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerCapabilities",
Expand Down Expand Up @@ -169,7 +173,13 @@ public void CreateScanJob()
_serverState.IsProcessing = true;
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
_serverState.AddJob(jobInfo);
Response.Headers.Add("Location", $"{Request.Url}/{jobInfo.Id}");
var uri = Request.Url;
if (Request.IsSecureConnection)
{
// Fix https://github.com/unosquare/embedio/issues/593
uri = new UriBuilder(uri) { Scheme = "https" }.Uri;
}
Response.Headers.Add("Location", $"{uri}/{jobInfo.Id}");
Response.StatusCode = 201; // Created
}

Expand Down
45 changes: 35 additions & 10 deletions NAPS2.Escl.Server/EsclServer.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography.X509Certificates;
using EmbedIO;
using EmbedIO.WebApi;
using Microsoft.Extensions.Logging;
Expand All @@ -11,11 +12,15 @@ static EsclServer()
{
Swan.Logging.Logger.NoLogging();
}

private readonly Dictionary<EsclDeviceConfig, DeviceContext> _devices = new();
private bool _started;
private CancellationTokenSource? _cts;

public EsclSecurityPolicy SecurityPolicy { get; set; }

public X509Certificate2? Certificate { get; set; }

public ILogger Logger { get; set; } = NullLogger.Instance;

public void AddDevice(EsclDeviceConfig deviceConfig)
Expand Down Expand Up @@ -45,6 +50,11 @@ public Task Start()
{
return Task.CompletedTask;
}
if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) && Certificate == null)
{
throw new EsclSecurityPolicyViolationException(
$"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified");
}
_started = true;
_cts = new CancellationTokenSource();

Expand All @@ -63,24 +73,39 @@ private async Task StartServerAndAdvertise(DeviceContext deviceCtx)
var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, deviceCtx.Cts.Token).Token;
// Try to run the server with the port specified in the EsclDeviceConfig first. If that fails, try random ports
// instead, and store the actually-used port back in EsclDeviceConfig so it can be advertised correctly.
await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port =>
bool hasHttp = !SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps);
bool hasHttps = Certificate != null;
if (hasHttp)
{
await StartServer(deviceCtx, port, cancelToken);
deviceCtx.Config.Port = port;
}, cancelToken);
deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config);
await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port =>
{
await StartServer(deviceCtx, port, false, cancelToken);
deviceCtx.Config.Port = port;
}, cancelToken);
}
if (hasHttps)
{
await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.TlsPort, async tlsPort =>
{
await StartServer(deviceCtx, tlsPort, true, cancelToken);
deviceCtx.Config.TlsPort = tlsPort;
}, cancelToken);
}
deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config, hasHttp, hasHttps);
}

private async Task StartServer(DeviceContext deviceCtx, int port, CancellationToken cancelToken)
private async Task StartServer(DeviceContext deviceCtx, int port, bool tls, CancellationToken cancelToken)
{
var url = $"http://+:{port}/";
var protocol = tls ? "https" : "http";
var url = $"{protocol}://+:{port}/";
deviceCtx.ServerState = new EsclServerState();
var server = new WebServer(o => o
.WithMode(HttpListenerMode.EmbedIO)
.WithUrlPrefix(url))
.WithUrlPrefix(url)
.WithCertificate((tls ? Certificate : null)!))
.HandleUnhandledException(UnhandledServerException)
.WithWebApi("/eSCL",
m => m.WithController(() => new EsclApiController(deviceCtx.Config, deviceCtx.ServerState, Logger)));
m => m.WithController(() => new EsclApiController(deviceCtx.Config, deviceCtx.ServerState, SecurityPolicy, Logger)));
await server.StartAsync(cancelToken);
}

Expand Down
128 changes: 110 additions & 18 deletions NAPS2.Escl.Server/MdnsAdvertiser.cs
Original file line number Diff line number Diff line change
@@ -1,55 +1,147 @@
using Makaretu.Dns;
using Makaretu.Dns.Resolving;

namespace NAPS2.Escl.Server;

public class MdnsAdvertiser : IDisposable
{
private readonly ServiceDiscovery _sd;
private readonly Dictionary<string, ServiceProfile> _serviceProfiles = new();
private readonly Dictionary<string, ServiceProfile> _serviceProfiles2 = new();

public MdnsAdvertiser()
{
_sd = new ServiceDiscovery();
}

public void AdvertiseDevice(EsclDeviceConfig deviceConfig)
public void AdvertiseDevice(EsclDeviceConfig deviceConfig, bool hasHttp, bool hasHttps)
{
var caps = deviceConfig.Capabilities;
if (caps.Uuid == null)
{
throw new ArgumentException("UUID must be specified");
}
if (!hasHttp && !hasHttps)
{
return;
}
var name = caps.MakeAndModel;
var service = new ServiceProfile(name, "_uscan._tcp", (ushort) deviceConfig.Port);

// HTTP+HTTPS should be handled by responding with the relevant records for both _uscan and _uscans when either
// is queried. This isn't handled out-of-the-box by the MDNS library so we need to do some extra work.
var httpProfile = new ServiceProfile(name, "_uscan._tcp", (ushort) deviceConfig.Port);
var httpsProfile = new ServiceProfile(name, "_uscans._tcp", (ushort) deviceConfig.TlsPort);
// If only one of HTTP or HTTPS is enabled, then we use that as the service. If both are enabled, we use the
// HTTP service as a baseline and then hack in the HTTPS records later.
var service = hasHttp ? httpProfile : httpsProfile;

var domain = $"naps2-{caps.Uuid}";
service.HostName = DomainName.Join(domain, service.Domain);
service.AddProperty("txtvers", "1");
service.AddProperty("Vers", "2.0"); // TODO: verify
if (deviceConfig.Capabilities.IconPng != null)
var hostName = DomainName.Join(domain, service.Domain);

// Replace the default TXT record with the first TXT record (HTTP if used, HTTPS otherwise)
service.Resources.RemoveAll(x => x is TXTRecord);
service.Resources.Add(CreateTxtRecord(deviceConfig, hasHttp, service, caps, name));

// NSEC records are recommended by RFC6762 to annotate that there's no more info for this host
service.Resources.Add(new NSECRecord
{ Name = hostName, NextOwnerName = hostName, Types = [DnsType.A, DnsType.AAAA] });

if (hasHttp && hasHttps)
{
service.AddProperty("representation", $"http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png");
// If both HTTP and HTTPS are enabled, we add the extra HTTPS records here
service.Resources.Add(new PTRRecord
{
Name = httpsProfile.QualifiedServiceName,
DomainName = httpsProfile.FullyQualifiedName
});
service.Resources.Add(new SRVRecord
{
Name = httpsProfile.FullyQualifiedName,
Port = (ushort) deviceConfig.TlsPort
});
service.Resources.Add(CreateTxtRecord(deviceConfig, false, httpsProfile, caps, name));
}
service.AddProperty("rs", "eSCL");
service.AddProperty("ty", name);
service.AddProperty("pdl", "application/pdf,image/jpeg,image/png");
// TODO: Actual adf/duplex, etc.
service.AddProperty("uuid", caps.Uuid);
service.AddProperty("cs", "color,grayscale,binary");
service.AddProperty("is", "platen"); // and ,adf
service.AddProperty("duplex", "F");

// The default HostName isn't correct, it should be "naps2-uuid.local" (the actual host) instead of
// "name._uscan.local" (the service name)
service.HostName = hostName;

// Send the full set of HTTP/HTTPS records to anyone currently listening
_sd.Announce(service);

// Set up to respond to _uscan/_uscans queries with our records.
_sd.Advertise(service);
if (hasHttp && hasHttps)
{
// Add _uscans to the available services (_uscan was already mapped in Advertise())
_sd.NameServer.Catalog[ServiceDiscovery.ServiceName].Resources.Add(new PTRRecord
{ Name = ServiceDiscovery.ServiceName, DomainName = httpsProfile.QualifiedServiceName });
// Cross-reference _uscan to the HTTPS records
_sd.NameServer.Catalog[httpProfile.QualifiedServiceName].Resources.Add(new PTRRecord
{ Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName });
// Add a _uscans reference with both HTTP and HTTPS records
_sd.NameServer.Catalog[httpsProfile.QualifiedServiceName] = new Node
{
Name = httpsProfile.QualifiedServiceName, Authoritative = true, Resources =
{
new PTRRecord
{ Name = httpProfile.QualifiedServiceName, DomainName = httpProfile.FullyQualifiedName },
new PTRRecord
{ Name = httpsProfile.QualifiedServiceName, DomainName = httpsProfile.FullyQualifiedName }
}
};
}

// Persist the profiles so they can be unadvertised later
_serviceProfiles.Add(caps.Uuid, service);
if (hasHttp && hasHttps)
{
_serviceProfiles2.Add(caps.Uuid, httpsProfile);
}
}

private static TXTRecord CreateTxtRecord(EsclDeviceConfig deviceConfig, bool http, ServiceProfile service,
EsclCapabilities caps, string? name)
{
var record = new TXTRecord();
record.Name = service.FullyQualifiedName;
record.Strings.Add("txtvers=1");
record.Strings.Add("Vers=2.0"); // TODO: verify
if (deviceConfig.Capabilities.IconPng != null)
{
record.Strings.Add(
http
? $"representation=http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png"
: $"representation=https://naps2-{caps.Uuid}.local.:{deviceConfig.TlsPort}/eSCL/icon.png");
}
record.Strings.Add("rs=eSCL");
record.Strings.Add($"ty={name}");
record.Strings.Add("pdl=application/pdf,image/jpeg,image/png");
// TODO: Actual adf/duplex, etc.
record.Strings.Add($"uuid={caps.Uuid}");
record.Strings.Add("cs=color,grayscale,binary");
record.Strings.Add("is=platen"); // and ,adf
record.Strings.Add("duplex=F");
return record;
}

public void UnadvertiseDevice(EsclDeviceConfig deviceConfig)
{
if (deviceConfig.Capabilities.Uuid == null)
var uuid = deviceConfig.Capabilities.Uuid;
if (uuid == null)
{
throw new ArgumentException("UUID must be specified");
}
_sd.Unadvertise(_serviceProfiles[deviceConfig.Capabilities.Uuid]);
_serviceProfiles.Remove(deviceConfig.Capabilities.Uuid);
if (_serviceProfiles.ContainsKey(uuid))
{
_sd.Unadvertise(_serviceProfiles[uuid]);
_serviceProfiles.Remove(uuid);
}
if (_serviceProfiles2.ContainsKey(uuid))
{
_sd.Unadvertise(_serviceProfiles2[uuid]);
_serviceProfiles2.Remove(uuid);
}
}

public void Dispose()
Expand Down
9 changes: 9 additions & 0 deletions NAPS2.Escl.Tests/ClientServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public async Task ClientServer()
Host = $"[{IPAddress.IPv6Loopback}]",
RemoteEndpoint = IPAddress.IPv6Loopback,
Port = deviceConfig.Port,
TlsPort = deviceConfig.TlsPort,
RootUrl = "eSCL",
Tls = false,
Uuid = uuid
Expand All @@ -43,4 +44,12 @@ public async Task ClientServer()
Assert.Equal("HP Blah", caps.MakeAndModel);
Assert.Equal("123abc", caps.SerialNumber);
}

[Fact]
public async Task StartTlsServerWithoutCertificate()
{
using var server = new EsclServer();
server.SecurityPolicy = EsclSecurityPolicy.RequireHttps;
await Assert.ThrowsAsync<EsclSecurityPolicyViolationException>(() => server.Start());
}
}
1 change: 1 addition & 0 deletions NAPS2.Escl.Usb/EsclUsbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public void ConnectToDevice()
Host = IPAddress.Loopback.ToString(),
RemoteEndpoint = IPAddress.Loopback,
Port = port,
TlsPort = 0,
RootUrl = "eSCL",
Tls = false,
Uuid = Guid.Empty.ToString("D")
Expand Down
Loading

0 comments on commit 05bd7d9

Please sign in to comment.