mirror of
https://github.com/cyanfish/naps2.git
synced 2024-08-16 18:50:45 +03:00
parent
79bba70370
commit
c07bcddf3b
@ -14,12 +14,15 @@ internal class EsclApiController : WebApiController
|
|||||||
|
|
||||||
private readonly EsclDeviceConfig _deviceConfig;
|
private readonly EsclDeviceConfig _deviceConfig;
|
||||||
private readonly EsclServerState _serverState;
|
private readonly EsclServerState _serverState;
|
||||||
|
private readonly EsclSecurityPolicy _securityPolicy;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, ILogger logger)
|
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState,
|
||||||
|
EsclSecurityPolicy securityPolicy, ILogger logger)
|
||||||
{
|
{
|
||||||
_deviceConfig = deviceConfig;
|
_deviceConfig = deviceConfig;
|
||||||
_serverState = serverState;
|
_serverState = serverState;
|
||||||
|
_securityPolicy = securityPolicy;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +30,8 @@ internal class EsclApiController : WebApiController
|
|||||||
public async Task GetScannerCapabilities()
|
public async Task GetScannerCapabilities()
|
||||||
{
|
{
|
||||||
var caps = _deviceConfig.Capabilities;
|
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 =
|
var doc =
|
||||||
EsclXmlHelper.CreateDocAsString(
|
EsclXmlHelper.CreateDocAsString(
|
||||||
new XElement(ScanNs + "ScannerCapabilities",
|
new XElement(ScanNs + "ScannerCapabilities",
|
||||||
@ -169,7 +173,13 @@ internal class EsclApiController : WebApiController
|
|||||||
_serverState.IsProcessing = true;
|
_serverState.IsProcessing = true;
|
||||||
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
|
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
|
||||||
_serverState.AddJob(jobInfo);
|
_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
|
Response.StatusCode = 201; // Created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using EmbedIO;
|
using EmbedIO;
|
||||||
using EmbedIO.WebApi;
|
using EmbedIO.WebApi;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -11,11 +12,15 @@ public class EsclServer : IEsclServer
|
|||||||
{
|
{
|
||||||
Swan.Logging.Logger.NoLogging();
|
Swan.Logging.Logger.NoLogging();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Dictionary<EsclDeviceConfig, DeviceContext> _devices = new();
|
private readonly Dictionary<EsclDeviceConfig, DeviceContext> _devices = new();
|
||||||
private bool _started;
|
private bool _started;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public EsclSecurityPolicy SecurityPolicy { get; set; }
|
||||||
|
|
||||||
|
public X509Certificate2? Certificate { get; set; }
|
||||||
|
|
||||||
public ILogger Logger { get; set; } = NullLogger.Instance;
|
public ILogger Logger { get; set; } = NullLogger.Instance;
|
||||||
|
|
||||||
public void AddDevice(EsclDeviceConfig deviceConfig)
|
public void AddDevice(EsclDeviceConfig deviceConfig)
|
||||||
@ -45,6 +50,11 @@ public class EsclServer : IEsclServer
|
|||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ServerRequireHttps) && Certificate == null)
|
||||||
|
{
|
||||||
|
throw new EsclSecurityPolicyViolationException(
|
||||||
|
$"EsclSecurityPolicy of {SecurityPolicy} needs a certificate to be specified");
|
||||||
|
}
|
||||||
_started = true;
|
_started = true;
|
||||||
_cts = new CancellationTokenSource();
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
@ -63,24 +73,39 @@ public class EsclServer : IEsclServer
|
|||||||
var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, deviceCtx.Cts.Token).Token;
|
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
|
// 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.
|
// 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);
|
await PortFinder.RunWithSpecifiedOrRandomPort(deviceCtx.Config.Port, async port =>
|
||||||
deviceCtx.Config.Port = port;
|
{
|
||||||
}, cancelToken);
|
await StartServer(deviceCtx, port, false, cancelToken);
|
||||||
deviceCtx.Advertiser.AdvertiseDevice(deviceCtx.Config);
|
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();
|
deviceCtx.ServerState = new EsclServerState();
|
||||||
var server = new WebServer(o => o
|
var server = new WebServer(o => o
|
||||||
.WithMode(HttpListenerMode.EmbedIO)
|
.WithMode(HttpListenerMode.EmbedIO)
|
||||||
.WithUrlPrefix(url))
|
.WithUrlPrefix(url)
|
||||||
|
.WithCertificate((tls ? Certificate : null)!))
|
||||||
.HandleUnhandledException(UnhandledServerException)
|
.HandleUnhandledException(UnhandledServerException)
|
||||||
.WithWebApi("/eSCL",
|
.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);
|
await server.StartAsync(cancelToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Makaretu.Dns;
|
using Makaretu.Dns;
|
||||||
|
using Makaretu.Dns.Resolving;
|
||||||
|
|
||||||
namespace NAPS2.Escl.Server;
|
namespace NAPS2.Escl.Server;
|
||||||
|
|
||||||
@ -6,50 +7,141 @@ public class MdnsAdvertiser : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly ServiceDiscovery _sd;
|
private readonly ServiceDiscovery _sd;
|
||||||
private readonly Dictionary<string, ServiceProfile> _serviceProfiles = new();
|
private readonly Dictionary<string, ServiceProfile> _serviceProfiles = new();
|
||||||
|
private readonly Dictionary<string, ServiceProfile> _serviceProfiles2 = new();
|
||||||
|
|
||||||
public MdnsAdvertiser()
|
public MdnsAdvertiser()
|
||||||
{
|
{
|
||||||
_sd = new ServiceDiscovery();
|
_sd = new ServiceDiscovery();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AdvertiseDevice(EsclDeviceConfig deviceConfig)
|
public void AdvertiseDevice(EsclDeviceConfig deviceConfig, bool hasHttp, bool hasHttps)
|
||||||
{
|
{
|
||||||
var caps = deviceConfig.Capabilities;
|
var caps = deviceConfig.Capabilities;
|
||||||
if (caps.Uuid == null)
|
if (caps.Uuid == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentException("UUID must be specified");
|
throw new ArgumentException("UUID must be specified");
|
||||||
}
|
}
|
||||||
|
if (!hasHttp && !hasHttps)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
var name = caps.MakeAndModel;
|
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}";
|
var domain = $"naps2-{caps.Uuid}";
|
||||||
service.HostName = DomainName.Join(domain, service.Domain);
|
var hostName = DomainName.Join(domain, service.Domain);
|
||||||
service.AddProperty("txtvers", "1");
|
|
||||||
service.AddProperty("Vers", "2.0"); // TODO: verify
|
// 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)
|
||||||
|
{
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
if (deviceConfig.Capabilities.IconPng != null)
|
||||||
{
|
{
|
||||||
service.AddProperty("representation", $"http://naps2-{caps.Uuid}.local.:{deviceConfig.Port}/eSCL/icon.png");
|
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");
|
||||||
}
|
}
|
||||||
service.AddProperty("rs", "eSCL");
|
record.Strings.Add("rs=eSCL");
|
||||||
service.AddProperty("ty", name);
|
record.Strings.Add($"ty={name}");
|
||||||
service.AddProperty("pdl", "application/pdf,image/jpeg,image/png");
|
record.Strings.Add("pdl=application/pdf,image/jpeg,image/png");
|
||||||
// TODO: Actual adf/duplex, etc.
|
// TODO: Actual adf/duplex, etc.
|
||||||
service.AddProperty("uuid", caps.Uuid);
|
record.Strings.Add($"uuid={caps.Uuid}");
|
||||||
service.AddProperty("cs", "color,grayscale,binary");
|
record.Strings.Add("cs=color,grayscale,binary");
|
||||||
service.AddProperty("is", "platen"); // and ,adf
|
record.Strings.Add("is=platen"); // and ,adf
|
||||||
service.AddProperty("duplex", "F");
|
record.Strings.Add("duplex=F");
|
||||||
_sd.Announce(service);
|
return record;
|
||||||
_sd.Advertise(service);
|
|
||||||
_serviceProfiles.Add(caps.Uuid, service);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UnadvertiseDevice(EsclDeviceConfig deviceConfig)
|
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");
|
throw new ArgumentException("UUID must be specified");
|
||||||
}
|
}
|
||||||
_sd.Unadvertise(_serviceProfiles[deviceConfig.Capabilities.Uuid]);
|
if (_serviceProfiles.ContainsKey(uuid))
|
||||||
_serviceProfiles.Remove(deviceConfig.Capabilities.Uuid);
|
{
|
||||||
|
_sd.Unadvertise(_serviceProfiles[uuid]);
|
||||||
|
_serviceProfiles.Remove(uuid);
|
||||||
|
}
|
||||||
|
if (_serviceProfiles2.ContainsKey(uuid))
|
||||||
|
{
|
||||||
|
_sd.Unadvertise(_serviceProfiles2[uuid]);
|
||||||
|
_serviceProfiles2.Remove(uuid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
@ -34,6 +34,7 @@ public class ClientServerTests
|
|||||||
Host = $"[{IPAddress.IPv6Loopback}]",
|
Host = $"[{IPAddress.IPv6Loopback}]",
|
||||||
RemoteEndpoint = IPAddress.IPv6Loopback,
|
RemoteEndpoint = IPAddress.IPv6Loopback,
|
||||||
Port = deviceConfig.Port,
|
Port = deviceConfig.Port,
|
||||||
|
TlsPort = deviceConfig.TlsPort,
|
||||||
RootUrl = "eSCL",
|
RootUrl = "eSCL",
|
||||||
Tls = false,
|
Tls = false,
|
||||||
Uuid = uuid
|
Uuid = uuid
|
||||||
@ -43,4 +44,12 @@ public class ClientServerTests
|
|||||||
Assert.Equal("HP Blah", caps.MakeAndModel);
|
Assert.Equal("HP Blah", caps.MakeAndModel);
|
||||||
Assert.Equal("123abc", caps.SerialNumber);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
@ -52,6 +52,7 @@ public class EsclUsbContext : IDisposable
|
|||||||
Host = IPAddress.Loopback.ToString(),
|
Host = IPAddress.Loopback.ToString(),
|
||||||
RemoteEndpoint = IPAddress.Loopback,
|
RemoteEndpoint = IPAddress.Loopback,
|
||||||
Port = port,
|
Port = port,
|
||||||
|
TlsPort = 0,
|
||||||
RootUrl = "eSCL",
|
RootUrl = "eSCL",
|
||||||
Tls = false,
|
Tls = false,
|
||||||
Uuid = Guid.Empty.ToString("D")
|
Uuid = Guid.Empty.ToString("D")
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Security.Authentication;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -13,7 +14,13 @@ public class EsclClient
|
|||||||
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
|
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
|
||||||
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
|
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
|
||||||
|
|
||||||
private static readonly HttpClientHandler HttpClientHandler = new()
|
// Clients that verify HTTPS certificates
|
||||||
|
private static readonly HttpClient VerifiedHttpClient = new();
|
||||||
|
private static readonly HttpClient VerifiedProgressHttpClient = new();
|
||||||
|
private static readonly HttpClient VerifiedDocumentHttpClient = new();
|
||||||
|
|
||||||
|
// Clients that don't verify HTTPS certificates
|
||||||
|
private static readonly HttpClientHandler UnverifiedHttpClientHandler = new()
|
||||||
{
|
{
|
||||||
// ESCL certificates are generally self-signed - we aren't trying to verify server authenticity, just ensure
|
// ESCL certificates are generally self-signed - we aren't trying to verify server authenticity, just ensure
|
||||||
// that the connection is encrypted and protect against passive interception.
|
// that the connection is encrypted and protect against passive interception.
|
||||||
@ -21,21 +28,38 @@ public class EsclClient
|
|||||||
};
|
};
|
||||||
// Sadly as we're still using .NET Framework on Windows, we're stuck with the old HttpClient implementation, which
|
// Sadly as we're still using .NET Framework on Windows, we're stuck with the old HttpClient implementation, which
|
||||||
// has trouble with concurrency. So we use a separate client for long running requests (Progress/NextDocument).
|
// has trouble with concurrency. So we use a separate client for long running requests (Progress/NextDocument).
|
||||||
private static readonly HttpClient HttpClient = new(HttpClientHandler);
|
private static readonly HttpClient UnverifiedHttpClient = new(UnverifiedHttpClientHandler);
|
||||||
private static readonly HttpClient ProgressHttpClient = new(HttpClientHandler);
|
private static readonly HttpClient UnverifiedProgressHttpClient = new(UnverifiedHttpClientHandler);
|
||||||
private static readonly HttpClient DocumentHttpClient = new(HttpClientHandler);
|
private static readonly HttpClient UnverifiedDocumentHttpClient = new(UnverifiedHttpClientHandler);
|
||||||
|
|
||||||
private readonly EsclService _service;
|
private readonly EsclService _service;
|
||||||
|
private bool _httpFallback;
|
||||||
|
|
||||||
public EsclClient(EsclService service)
|
public EsclClient(EsclService service)
|
||||||
{
|
{
|
||||||
_service = service;
|
_service = service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public EsclSecurityPolicy SecurityPolicy { get; set; }
|
||||||
|
|
||||||
public ILogger Logger { get; set; } = NullLogger.Instance;
|
public ILogger Logger { get; set; } = NullLogger.Instance;
|
||||||
|
|
||||||
public CancellationToken CancelToken { get; set; }
|
public CancellationToken CancelToken { get; set; }
|
||||||
|
|
||||||
|
private HttpClient HttpClient => SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate)
|
||||||
|
? VerifiedHttpClient
|
||||||
|
: UnverifiedHttpClient;
|
||||||
|
|
||||||
|
private HttpClient ProgressHttpClient =>
|
||||||
|
SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate)
|
||||||
|
? VerifiedProgressHttpClient
|
||||||
|
: UnverifiedProgressHttpClient;
|
||||||
|
|
||||||
|
private HttpClient DocumentHttpClient =>
|
||||||
|
SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate)
|
||||||
|
? VerifiedDocumentHttpClient
|
||||||
|
: UnverifiedDocumentHttpClient;
|
||||||
|
|
||||||
public async Task<EsclCapabilities> GetCapabilities()
|
public async Task<EsclCapabilities> GetCapabilities()
|
||||||
{
|
{
|
||||||
var doc = await DoRequest("ScannerCapabilities");
|
var doc = await DoRequest("ScannerCapabilities");
|
||||||
@ -95,9 +119,13 @@ public class EsclClient
|
|||||||
OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor),
|
OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor),
|
||||||
new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat)));
|
new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat)));
|
||||||
var content = new StringContent(doc, Encoding.UTF8, "text/xml");
|
var content = new StringContent(doc, Encoding.UTF8, "text/xml");
|
||||||
var url = GetUrl($"/{_service.RootUrl}/ScanJobs");
|
var response = await WithHttpFallback(
|
||||||
Logger.LogDebug("ESCL POST {Url}", url);
|
() => GetUrl($"/{_service.RootUrl}/ScanJobs"),
|
||||||
var response = await HttpClient.PostAsync(url, content);
|
url =>
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ESCL POST {Url}", url);
|
||||||
|
return HttpClient.PostAsync(url, content);
|
||||||
|
});
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
Logger.LogDebug("POST OK");
|
Logger.LogDebug("POST OK");
|
||||||
|
|
||||||
@ -142,9 +170,13 @@ public class EsclClient
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
|
// TODO: Maybe check Content-Location on the response header to ensure no duplicate document?
|
||||||
var url = GetUrl($"{job.UriPath}/NextDocument");
|
var response = await WithHttpFallback(
|
||||||
Logger.LogDebug("ESCL GET {Url}", url);
|
() => GetUrl($"{job.UriPath}/NextDocument"),
|
||||||
var response = await DocumentHttpClient.GetAsync(url);
|
url =>
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ESCL GET {Url}", url);
|
||||||
|
return DocumentHttpClient.GetAsync(url);
|
||||||
|
});
|
||||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone)
|
||||||
{
|
{
|
||||||
// NotFound = end of scan, Gone = canceled
|
// NotFound = end of scan, Gone = canceled
|
||||||
@ -170,9 +202,13 @@ public class EsclClient
|
|||||||
|
|
||||||
public async Task<string> ErrorDetails(EsclJob job)
|
public async Task<string> ErrorDetails(EsclJob job)
|
||||||
{
|
{
|
||||||
var url = GetUrl($"{job.UriPath}/ErrorDetails");
|
var response = await WithHttpFallback(
|
||||||
Logger.LogDebug("ESCL GET {Url}", url);
|
() => GetUrl($"{job.UriPath}/ErrorDetails"),
|
||||||
var response = await HttpClient.GetAsync(url);
|
url =>
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ESCL GET {Url}", url);
|
||||||
|
return HttpClient.GetAsync(url);
|
||||||
|
});
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
Logger.LogDebug("GET OK");
|
Logger.LogDebug("GET OK");
|
||||||
return await response.Content.ReadAsStringAsync();
|
return await response.Content.ReadAsStringAsync();
|
||||||
@ -180,9 +216,13 @@ public class EsclClient
|
|||||||
|
|
||||||
public async Task CancelJob(EsclJob job)
|
public async Task CancelJob(EsclJob job)
|
||||||
{
|
{
|
||||||
var url = GetUrl(job.UriPath);
|
var response = await WithHttpFallback(
|
||||||
Logger.LogDebug("ESCL DELETE {Url}", url);
|
() => GetUrl(job.UriPath),
|
||||||
var response = await HttpClient.DeleteAsync(url);
|
url =>
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ESCL DELETE {Url}", url);
|
||||||
|
return HttpClient.DeleteAsync(url);
|
||||||
|
});
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("DELETE failed: {Status}", response.StatusCode);
|
Logger.LogDebug("DELETE failed: {Status}", response.StatusCode);
|
||||||
@ -195,9 +235,13 @@ public class EsclClient
|
|||||||
private async Task<XDocument> DoRequest(string endpoint)
|
private async Task<XDocument> DoRequest(string endpoint)
|
||||||
{
|
{
|
||||||
// TODO: Retry logic
|
// TODO: Retry logic
|
||||||
var url = GetUrl($"/{_service.RootUrl}/{endpoint}");
|
var response = await WithHttpFallback(
|
||||||
Logger.LogDebug("ESCL GET {Url}", url);
|
() => GetUrl($"/{_service.RootUrl}/{endpoint}"),
|
||||||
var response = await HttpClient.GetAsync(url, CancelToken);
|
url =>
|
||||||
|
{
|
||||||
|
Logger.LogDebug("ESCL GET {Url}", url);
|
||||||
|
return HttpClient.GetAsync(url, CancelToken);
|
||||||
|
});
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
Logger.LogDebug("GET OK");
|
Logger.LogDebug("GET OK");
|
||||||
var text = await response.Content.ReadAsStringAsync();
|
var text = await response.Content.ReadAsStringAsync();
|
||||||
@ -205,20 +249,47 @@ public class EsclClient
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetUrl(string endpoint)
|
private async Task<T> WithHttpFallback<T>(Func<string> urlFunc, Func<string, Task<T>> func)
|
||||||
{
|
{
|
||||||
var protocol = _service.Tls || _service.Port == 443 ? "https" : "http";
|
string url = urlFunc();
|
||||||
return $"{protocol}://{GetHostAndPort()}{endpoint}";
|
try
|
||||||
|
{
|
||||||
|
return await func(url);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex) when (!SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) &&
|
||||||
|
!_httpFallback &&
|
||||||
|
url.StartsWith("https://") && (
|
||||||
|
ex.InnerException is AuthenticationException ||
|
||||||
|
ex.InnerException?.InnerException is AuthenticationException))
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ex, "TLS authentication error; falling back to HTTP");
|
||||||
|
_httpFallback = true;
|
||||||
|
url = urlFunc();
|
||||||
|
return await func(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetHostAndPort()
|
private string GetUrl(string endpoint)
|
||||||
{
|
{
|
||||||
var host = new IPEndPoint(_service.RemoteEndpoint, _service.Port).ToString();
|
bool tls = (_service.Tls || _service.Port == 443) && !_httpFallback;
|
||||||
|
if (SecurityPolicy.HasFlag(EsclSecurityPolicy.ClientRequireHttps) && !tls)
|
||||||
|
{
|
||||||
|
throw new EsclSecurityPolicyViolationException(
|
||||||
|
$"EsclSecurityPolicy of {SecurityPolicy} doesn't allow HTTP connections");
|
||||||
|
}
|
||||||
|
var protocol = tls ? "https" : "http";
|
||||||
|
return $"{protocol}://{GetHostAndPort(_service.Tls && !_httpFallback)}{endpoint}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetHostAndPort(bool tls)
|
||||||
|
{
|
||||||
|
var port = tls ? _service.TlsPort : _service.Port;
|
||||||
|
var host = new IPEndPoint(_service.RemoteEndpoint, port).ToString();
|
||||||
#if NET6_0_OR_GREATER
|
#if NET6_0_OR_GREATER
|
||||||
if (OperatingSystem.IsMacOS())
|
if (OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
// Using the mDNS hostname is more reliable on Mac (but doesn't work at all on Windows)
|
// Using the mDNS hostname is more reliable on Mac (but doesn't work at all on Windows)
|
||||||
host = $"{_service.Host}:{_service.Port}";
|
host = $"{_service.Host}:{port}";
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
return host;
|
return host;
|
||||||
|
@ -25,10 +25,15 @@ public class EsclService
|
|||||||
public required IPAddress RemoteEndpoint { get; init; }
|
public required IPAddress RemoteEndpoint { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The port of the ESCL service.
|
/// The HTTP port of the ESCL service.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required int Port { get; init; }
|
public required int Port { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The HTTPS port of the ESCL service.
|
||||||
|
/// </summary>
|
||||||
|
public required int TlsPort { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether to use HTTPS for the connection.
|
/// Whether to use HTTPS for the connection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -19,15 +19,24 @@ public class EsclServiceLocator : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var service = ParseService(args);
|
if (args.ServiceInstanceName.Labels[1] is not ("_uscan" or "_uscans"))
|
||||||
var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6);
|
|
||||||
if (!_locatedServices.Add(serviceKey))
|
|
||||||
{
|
{
|
||||||
// Don't callback for duplicates
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, uuid {Uuid}",
|
var service = ParseService(args);
|
||||||
service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.Uuid);
|
// TODO: Does the IP really make the device distinct? Not that it should matter in practice, but still.
|
||||||
|
// TODO: We definitely want to de-duplicate HTTP/HTTPS, but I'm not sure how to do that. Remind me how
|
||||||
|
var serviceKey = new ServiceKey(service.ScannerName, service.Uuid, service.Port, service.IpV4, service.IpV6);
|
||||||
|
lock (_locatedServices)
|
||||||
|
{
|
||||||
|
if (!_locatedServices.Add(serviceKey))
|
||||||
|
{
|
||||||
|
// Don't callback for duplicates
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.LogDebug("Discovered ESCL Service: {Name}, instance {Instance}, endpoint {Endpoint}, ipv4 {Ipv4}, ipv6 {IpV6}, host {Host}, port {Port}, tlsPort {Port}, uuid {Uuid}",
|
||||||
|
service.ScannerName, args.ServiceInstanceName, args.RemoteEndPoint, service.IpV4, service.IpV6, service.Host, service.Port, service.TlsPort, service.Uuid);
|
||||||
serviceCallback(service);
|
serviceCallback(service);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -79,6 +88,7 @@ public class EsclServiceLocator : IDisposable
|
|||||||
bool isTls = false;
|
bool isTls = false;
|
||||||
IPAddress? ipv4 = null, ipv6 = null;
|
IPAddress? ipv4 = null, ipv6 = null;
|
||||||
int port = -1;
|
int port = -1;
|
||||||
|
int tlsPort = -1;
|
||||||
string? host = null;
|
string? host = null;
|
||||||
var props = new Dictionary<string, string>();
|
var props = new Dictionary<string, string>();
|
||||||
foreach (var record in args.Message.AdditionalRecords)
|
foreach (var record in args.Message.AdditionalRecords)
|
||||||
@ -95,10 +105,17 @@ public class EsclServiceLocator : IDisposable
|
|||||||
if (record is SRVRecord srv)
|
if (record is SRVRecord srv)
|
||||||
{
|
{
|
||||||
bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local"));
|
bool recordIsTls = srv.Name.IsSubdomainOf(DomainName.Join("_uscans", "_tcp", "local"));
|
||||||
|
if (recordIsTls)
|
||||||
|
{
|
||||||
|
tlsPort = srv.Port;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
port = srv.Port;
|
||||||
|
}
|
||||||
if (host == null || recordIsTls)
|
if (host == null || recordIsTls)
|
||||||
{
|
{
|
||||||
// HTTPS overrides HTTP but not the other way around
|
// HTTPS overrides HTTP but not the other way around
|
||||||
port = srv.Port;
|
|
||||||
host = srv.Target.ToString();
|
host = srv.Target.ToString();
|
||||||
isTls = recordIsTls;
|
isTls = recordIsTls;
|
||||||
}
|
}
|
||||||
@ -116,7 +133,7 @@ public class EsclServiceLocator : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
string? uuid = Get(props, "uuid");
|
string? uuid = Get(props, "uuid");
|
||||||
if ((ipv4 == null && ipv6 == null) || port == -1 || host == null || uuid == null)
|
if ((ipv4 == null && ipv6 == null) || (port == -1 && tlsPort == -1) || host == null || uuid == null)
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Missing host/IP/port/uuid");
|
throw new ArgumentException("Missing host/IP/port/uuid");
|
||||||
}
|
}
|
||||||
@ -128,6 +145,7 @@ public class EsclServiceLocator : IDisposable
|
|||||||
Host = host,
|
Host = host,
|
||||||
RemoteEndpoint = args.RemoteEndPoint.Address,
|
RemoteEndpoint = args.RemoteEndPoint.Address,
|
||||||
Port = port,
|
Port = port,
|
||||||
|
TlsPort = tlsPort,
|
||||||
Tls = isTls,
|
Tls = isTls,
|
||||||
Uuid = uuid,
|
Uuid = uuid,
|
||||||
ScannerName = props["ty"],
|
ScannerName = props["ty"],
|
||||||
|
24
NAPS2.Escl/EsclSecurityPolicy.cs
Normal file
24
NAPS2.Escl/EsclSecurityPolicy.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
namespace NAPS2.Escl;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum EsclSecurityPolicy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Allow both HTTP and HTTPS connections.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
ServerRequireHttps = 1,
|
||||||
|
ClientRequireHttps = 2,
|
||||||
|
ClientRequireHttpOrTrustedCertificate = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only allow HTTPS connections, but clients will accept self-signed certificates.
|
||||||
|
/// </summary>
|
||||||
|
RequireHttps = ServerRequireHttps | ClientRequireHttps,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only allow HTTPS connections, and clients will only accept trusted certificates.
|
||||||
|
/// </summary>
|
||||||
|
RequireTrustedCertificate = RequireHttps | ClientRequireHttpOrTrustedCertificate
|
||||||
|
}
|
3
NAPS2.Escl/EsclSecurityPolicyViolationException.cs
Normal file
3
NAPS2.Escl/EsclSecurityPolicyViolationException.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
namespace NAPS2.Escl;
|
||||||
|
|
||||||
|
public class EsclSecurityPolicyViolationException(string message) : Exception(message);
|
@ -7,4 +7,6 @@ public class EsclDeviceConfig
|
|||||||
public required Func<EsclScanSettings, IEsclScanJob> CreateJob { get; init; }
|
public required Func<EsclScanSettings, IEsclScanJob> CreateJob { get; init; }
|
||||||
|
|
||||||
public int Port { get; set; }
|
public int Port { get; set; }
|
||||||
|
|
||||||
|
public int TlsPort { get; set; }
|
||||||
}
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace NAPS2.Escl.Server;
|
namespace NAPS2.Escl.Server;
|
||||||
@ -8,5 +9,7 @@ public interface IEsclServer : IDisposable
|
|||||||
void RemoveDevice(EsclDeviceConfig deviceConfig);
|
void RemoveDevice(EsclDeviceConfig deviceConfig);
|
||||||
Task Start();
|
Task Start();
|
||||||
Task Stop();
|
Task Stop();
|
||||||
|
public EsclSecurityPolicy SecurityPolicy { get; set; }
|
||||||
|
public X509Certificate2? Certificate { get; set; }
|
||||||
ILogger Logger { get; set; }
|
ILogger Logger { get; set; }
|
||||||
}
|
}
|
@ -1,10 +1,10 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using NAPS2.Config.Model;
|
using NAPS2.Config.Model;
|
||||||
|
using NAPS2.Escl;
|
||||||
using NAPS2.ImportExport.Email;
|
using NAPS2.ImportExport.Email;
|
||||||
using NAPS2.ImportExport.Images;
|
using NAPS2.ImportExport.Images;
|
||||||
using NAPS2.Pdf;
|
using NAPS2.Pdf;
|
||||||
using NAPS2.Ocr;
|
using NAPS2.Ocr;
|
||||||
using NAPS2.Remoting.Server;
|
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
using NAPS2.Scan.Batch;
|
using NAPS2.Scan.Batch;
|
||||||
|
|
||||||
@ -147,6 +147,12 @@ public class CommonConfig
|
|||||||
[Common]
|
[Common]
|
||||||
public DockStyle ProfilesToolStripDock { get; set; }
|
public DockStyle ProfilesToolStripDock { get; set; }
|
||||||
|
|
||||||
|
[Common]
|
||||||
|
public EsclSecurityPolicy EsclSecurityPolicy { get; set; }
|
||||||
|
|
||||||
|
[Common]
|
||||||
|
public string? EsclServerCertificatePath { get; set; }
|
||||||
|
|
||||||
[App]
|
[App]
|
||||||
public EventType EventLogging { get; set; }
|
public EventType EventLogging { get; set; }
|
||||||
|
|
||||||
|
@ -120,6 +120,8 @@ public class ConfigSerializer : VersionedSerializer<ConfigStorage<CommonConfig>>
|
|||||||
storage.Set(x => x.OcrLanguageCode, c.OcrDefaultLanguage);
|
storage.Set(x => x.OcrLanguageCode, c.OcrDefaultLanguage);
|
||||||
storage.Set(x => x.OcrMode, c.OcrDefaultMode);
|
storage.Set(x => x.OcrMode, c.OcrDefaultMode);
|
||||||
storage.Set(x => x.OcrAfterScanning, c.OcrDefaultAfterScanning);
|
storage.Set(x => x.OcrAfterScanning, c.OcrDefaultAfterScanning);
|
||||||
|
storage.Set(x => x.EsclSecurityPolicy, c.EsclSecurityPolicy);
|
||||||
|
storage.Set(x => x.EsclServerCertificatePath, c.EsclServerCertificatePath);
|
||||||
storage.Set(x => x.EventLogging, c.EventLogging);
|
storage.Set(x => x.EventLogging, c.EventLogging);
|
||||||
storage.Set(x => x.KeyboardShortcuts, c.KeyboardShortcuts ?? new KeyboardShortcuts());
|
storage.Set(x => x.KeyboardShortcuts, c.KeyboardShortcuts ?? new KeyboardShortcuts());
|
||||||
return storage;
|
return storage;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Xml.Serialization;
|
using System.Xml.Serialization;
|
||||||
|
using NAPS2.Escl;
|
||||||
using NAPS2.Ocr;
|
using NAPS2.Ocr;
|
||||||
using NAPS2.Pdf;
|
using NAPS2.Pdf;
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
@ -84,6 +85,10 @@ public class AppConfigV0
|
|||||||
|
|
||||||
public PdfCompat ForcePdfCompat { get; set; }
|
public PdfCompat ForcePdfCompat { get; set; }
|
||||||
|
|
||||||
|
public EsclSecurityPolicy EsclSecurityPolicy { get; set; }
|
||||||
|
|
||||||
|
public string? EsclServerCertificatePath { get; set; }
|
||||||
|
|
||||||
public EventType EventLogging { get; set; }
|
public EventType EventLogging { get; set; }
|
||||||
|
|
||||||
public KeyboardShortcuts? KeyboardShortcuts { get; set; }
|
public KeyboardShortcuts? KeyboardShortcuts { get; set; }
|
||||||
|
@ -3,7 +3,6 @@ using Eto.Forms;
|
|||||||
using NAPS2.EtoForms.Layout;
|
using NAPS2.EtoForms.Layout;
|
||||||
using NAPS2.Remoting.Server;
|
using NAPS2.Remoting.Server;
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
using NAPS2.Scan.Exceptions;
|
|
||||||
using NAPS2.Scan.Internal;
|
using NAPS2.Scan.Internal;
|
||||||
|
|
||||||
namespace NAPS2.EtoForms.Ui;
|
namespace NAPS2.EtoForms.Ui;
|
||||||
@ -11,6 +10,7 @@ namespace NAPS2.EtoForms.Ui;
|
|||||||
public class SharedDeviceForm : EtoDialogBase
|
public class SharedDeviceForm : EtoDialogBase
|
||||||
{
|
{
|
||||||
private const int BASE_PORT = 9801;
|
private const int BASE_PORT = 9801;
|
||||||
|
private const int BASE_TLS_PORT = 9901;
|
||||||
|
|
||||||
private readonly IScanPerformer _scanPerformer;
|
private readonly IScanPerformer _scanPerformer;
|
||||||
private readonly ErrorOutput _errorOutput;
|
private readonly ErrorOutput _errorOutput;
|
||||||
@ -121,6 +121,8 @@ public class SharedDeviceForm : EtoDialogBase
|
|||||||
|
|
||||||
private int Port { get; set; }
|
private int Port { get; set; }
|
||||||
|
|
||||||
|
private int TlsPort { get; set; }
|
||||||
|
|
||||||
private Driver DeviceDriver
|
private Driver DeviceDriver
|
||||||
{
|
{
|
||||||
get => _twainDriver.Checked ? Driver.Twain
|
get => _twainDriver.Checked ? Driver.Twain
|
||||||
@ -159,6 +161,7 @@ public class SharedDeviceForm : EtoDialogBase
|
|||||||
_displayName.Text = SharedDevice?.Name ?? "";
|
_displayName.Text = SharedDevice?.Name ?? "";
|
||||||
CurrentDevice ??= SharedDevice?.Device;
|
CurrentDevice ??= SharedDevice?.Device;
|
||||||
Port = SharedDevice?.Port ?? 0;
|
Port = SharedDevice?.Port ?? 0;
|
||||||
|
TlsPort = SharedDevice?.TlsPort ?? 0;
|
||||||
|
|
||||||
DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver;
|
DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver;
|
||||||
|
|
||||||
@ -187,7 +190,8 @@ public class SharedDeviceForm : EtoDialogBase
|
|||||||
{
|
{
|
||||||
Name = _displayName.Text,
|
Name = _displayName.Text,
|
||||||
Device = CurrentDevice!,
|
Device = CurrentDevice!,
|
||||||
Port = Port == 0 ? NextPort() : Port
|
Port = Port == 0 ? NextPort() : Port,
|
||||||
|
TlsPort = TlsPort == 0 ? NextTlsPort() : TlsPort
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,6 +206,17 @@ public class SharedDeviceForm : EtoDialogBase
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int NextTlsPort()
|
||||||
|
{
|
||||||
|
var devices = _sharedDeviceManager.SharedDevices;
|
||||||
|
int tlsPort = BASE_TLS_PORT;
|
||||||
|
while (devices.Any(x => x.TlsPort == tlsPort))
|
||||||
|
{
|
||||||
|
tlsPort++;
|
||||||
|
}
|
||||||
|
return tlsPort;
|
||||||
|
}
|
||||||
|
|
||||||
private void Ok_Click(object? sender, EventArgs e)
|
private void Ok_Click(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (_displayName.Text == "")
|
if (_displayName.Text == "")
|
||||||
|
@ -7,6 +7,7 @@ public record SharedDevice
|
|||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public required ScanDevice Device { get; init; }
|
public required ScanDevice Device { get; init; }
|
||||||
public int Port { get; init; }
|
public int Port { get; init; }
|
||||||
|
public int TlsPort { get; init; }
|
||||||
|
|
||||||
public virtual bool Equals(SharedDevice? other) =>
|
public virtual bool Equals(SharedDevice? other) =>
|
||||||
other is not null && Name == other.Name && Device == other.Device;
|
other is not null && Name == other.Name && Device == other.Device;
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NAPS2.Config.Model;
|
using NAPS2.Config.Model;
|
||||||
using NAPS2.Escl.Server;
|
using NAPS2.Escl.Server;
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
@ -10,6 +12,7 @@ public class SharedDeviceManager : ISharedDeviceManager
|
|||||||
{
|
{
|
||||||
private const int STARTUP_RETRY_INTERVAL = 10_000;
|
private const int STARTUP_RETRY_INTERVAL = 10_000;
|
||||||
|
|
||||||
|
private readonly ILogger _logger;
|
||||||
private readonly Naps2Config _config;
|
private readonly Naps2Config _config;
|
||||||
private readonly FileConfigScope<SharingConfig> _scope;
|
private readonly FileConfigScope<SharingConfig> _scope;
|
||||||
private readonly ScanServer _server;
|
private readonly ScanServer _server;
|
||||||
@ -19,11 +22,30 @@ public class SharedDeviceManager : ISharedDeviceManager
|
|||||||
|
|
||||||
public SharedDeviceManager(ScanningContext scanningContext, Naps2Config config, string sharedDevicesConfigPath)
|
public SharedDeviceManager(ScanningContext scanningContext, Naps2Config config, string sharedDevicesConfigPath)
|
||||||
{
|
{
|
||||||
|
_logger = scanningContext.Logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
_scope = ConfigScope.File(sharedDevicesConfigPath, new ConfigStorageSerializer<SharingConfig>(),
|
_scope = ConfigScope.File(sharedDevicesConfigPath, new ConfigStorageSerializer<SharingConfig>(),
|
||||||
ConfigScopeMode.ReadWrite);
|
ConfigScopeMode.ReadWrite);
|
||||||
_server = new ScanServer(scanningContext, new EsclServer());
|
_server = new ScanServer(scanningContext, new EsclServer());
|
||||||
_server.SetDefaultIcon(Icons.scanner_128);
|
_server.SetDefaultIcon(Icons.scanner_128);
|
||||||
|
_server.SecurityPolicy = config.Get(c => c.EsclSecurityPolicy);
|
||||||
|
if (config.Get(c => c.EsclServerCertificatePath) is { } certPath && !string.IsNullOrWhiteSpace(certPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_server.Certificate = new X509Certificate2(File.ReadAllBytes(certPath));
|
||||||
|
if (!_server.Certificate.HasPrivateKey)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
$"Certificate has no private key. Make sure it's installed in the local computer's certificate store. \"{certPath}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
$"Could not read X509 certificate from EsclServerCertificatePath \"{certPath}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
_server.InstanceId = _scope.GetOrDefault(c => c.InstanceId) ?? Guid.NewGuid();
|
_server.InstanceId = _scope.GetOrDefault(c => c.InstanceId) ?? Guid.NewGuid();
|
||||||
RegisterDevicesFromConfig();
|
RegisterDevicesFromConfig();
|
||||||
}
|
}
|
||||||
@ -54,7 +76,8 @@ public class SharedDeviceManager : ISharedDeviceManager
|
|||||||
if (_userStarted && SharedDevices.Any() && TakeLock())
|
if (_userStarted && SharedDevices.Any() && TakeLock())
|
||||||
{
|
{
|
||||||
ResetStartTimer();
|
ResetStartTimer();
|
||||||
_server.Start();
|
_server.Start().ContinueWith(t =>
|
||||||
|
_logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -67,7 +90,8 @@ public class SharedDeviceManager : ISharedDeviceManager
|
|||||||
{
|
{
|
||||||
_userStarted = false;
|
_userStarted = false;
|
||||||
ResetStartTimer();
|
ResetStartTimer();
|
||||||
_server.Stop();
|
_server.Stop().ContinueWith(t =>
|
||||||
|
_logger.LogError(t.Exception, "Error starting ScanServer"), TaskContinuationOptions.OnlyOnFaulted);
|
||||||
ReleaseLock();
|
ReleaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,7 +186,7 @@ public class SharedDeviceManager : ISharedDeviceManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void RegisterOnServer(SharedDevice device) =>
|
private void RegisterOnServer(SharedDevice device) =>
|
||||||
_server.RegisterDevice(device.Device, device.Name, device.Port);
|
_server.RegisterDevice(device.Device, device.Name, device.Port, device.TlsPort);
|
||||||
|
|
||||||
private void UnregisterOnServer(SharedDevice device) =>
|
private void UnregisterOnServer(SharedDevice device) =>
|
||||||
_server.UnregisterDevice(device.Device, device.Name);
|
_server.UnregisterDevice(device.Device, device.Name);
|
||||||
|
@ -242,6 +242,10 @@ internal class ScanPerformer : IScanPerformer
|
|||||||
// We use a worker process for SANE so we should clean up after each operation
|
// We use a worker process for SANE so we should clean up after each operation
|
||||||
KeepInitialized = false
|
KeepInitialized = false
|
||||||
},
|
},
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
SecurityPolicy = _config.Get(c => c.EsclSecurityPolicy)
|
||||||
|
},
|
||||||
KeyValueOptions = scanProfile.KeyValueOptions != null
|
KeyValueOptions = scanProfile.KeyValueOptions != null
|
||||||
? new KeyValueScanOptions(scanProfile.KeyValueOptions)
|
? new KeyValueScanOptions(scanProfile.KeyValueOptions)
|
||||||
: new KeyValueScanOptions(),
|
: new KeyValueScanOptions(),
|
||||||
|
10
NAPS2.Sdk.Tests/BinaryResources.Designer.cs
generated
10
NAPS2.Sdk.Tests/BinaryResources.Designer.cs
generated
@ -138,5 +138,15 @@ namespace NAPS2.Sdk.Tests {
|
|||||||
return ((byte[])(obj));
|
return ((byte[])(obj));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a localized resource of type System.Byte[].
|
||||||
|
/// </summary>
|
||||||
|
internal static byte[] testcert {
|
||||||
|
get {
|
||||||
|
object obj = ResourceManager.GetObject("testcert", resourceCulture);
|
||||||
|
return ((byte[])(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -142,4 +142,7 @@
|
|||||||
<data name="ocr_test_hebrew" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
<data name="ocr_test_hebrew" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||||
<value>Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>Resources\ocr_test_hebrew.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="testcert" type="System.Resources.ResXFileRef, System.Windows.Forms">
|
||||||
|
<value>Resources\testcert.pfx;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
48
NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs
Normal file
48
NAPS2.Sdk.Tests/Remoting/FallbackScanServerTests.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Security.Authentication;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using NAPS2.Escl;
|
||||||
|
using NAPS2.Scan;
|
||||||
|
using NAPS2.Sdk.Tests.Asserts;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace NAPS2.Sdk.Tests.Remoting;
|
||||||
|
|
||||||
|
public class FallbackScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper,
|
||||||
|
EsclSecurityPolicy.None, new X509Certificate2(BinaryResources.testcert))
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanFallbackFromHttpsToHttp()
|
||||||
|
{
|
||||||
|
_bridge.MockOutput = CreateScannedImages(ImageResources.dog);
|
||||||
|
var images = await _client.Scan(new ScanOptions
|
||||||
|
{
|
||||||
|
Device = _clientDevice,
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
// This policy makes sure HTTPS will fail due to an untrusted certificate, which simulates the case
|
||||||
|
// where we're failing due to the server only supporting obsolete TLS versions.
|
||||||
|
SecurityPolicy = EsclSecurityPolicy.ClientRequireHttpOrTrustedCertificate
|
||||||
|
}
|
||||||
|
}).ToListAsync();
|
||||||
|
Assert.Single(images);
|
||||||
|
ImageAsserts.Similar(ImageResources.dog, images[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanPreventedByTrustedCertificateSecurityPolicy()
|
||||||
|
{
|
||||||
|
var scanResult = _client.Scan(new ScanOptions
|
||||||
|
{
|
||||||
|
Device = _clientDevice,
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
SecurityPolicy = EsclSecurityPolicy.RequireTrustedCertificate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var exception = await Assert.ThrowsAsync<HttpRequestException>(async () => await scanResult.ToListAsync());
|
||||||
|
Assert.True(exception.InnerException is AuthenticationException ||
|
||||||
|
exception.InnerException?.InnerException is AuthenticationException);
|
||||||
|
}
|
||||||
|
}
|
@ -1,53 +1,15 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using NAPS2.Escl;
|
||||||
using NAPS2.Escl.Server;
|
|
||||||
using NAPS2.Remoting.Server;
|
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
using NAPS2.Scan.Exceptions;
|
using NAPS2.Scan.Exceptions;
|
||||||
using NAPS2.Scan.Internal;
|
|
||||||
using NAPS2.Sdk.Tests.Asserts;
|
using NAPS2.Sdk.Tests.Asserts;
|
||||||
using NAPS2.Sdk.Tests.Mocks;
|
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
namespace NAPS2.Sdk.Tests.Remoting;
|
namespace NAPS2.Sdk.Tests.Remoting;
|
||||||
|
|
||||||
public class ScanServerIntegrationTests : ContextualTests
|
public class ScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper)
|
||||||
{
|
{
|
||||||
private readonly ScanServer _server;
|
|
||||||
private readonly MockScanBridge _bridge;
|
|
||||||
private readonly ScanController _client;
|
|
||||||
private readonly ScanDevice _clientDevice;
|
|
||||||
|
|
||||||
public ScanServerIntegrationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
|
|
||||||
{
|
|
||||||
_server = new ScanServer(ScanningContext, new EsclServer());
|
|
||||||
|
|
||||||
// Set up a server connecting to a mock scan backend
|
|
||||||
_bridge = new MockScanBridge();
|
|
||||||
var scanBridgeFactory = Substitute.For<IScanBridgeFactory>();
|
|
||||||
scanBridgeFactory.Create(Arg.Any<ScanOptions>()).Returns(_bridge);
|
|
||||||
_server.ScanController = new ScanController(ScanningContext, scanBridgeFactory);
|
|
||||||
|
|
||||||
// Initialize the server with a single device with a unique ID for the test
|
|
||||||
var displayName = $"testName-{Guid.NewGuid()}";
|
|
||||||
ScanningContext.Logger.LogDebug("Display name: {Name}", displayName);
|
|
||||||
var serverDevice = new ScanDevice(ScanOptionsValidator.SystemDefaultDriver, "testID", "testName");
|
|
||||||
_server.RegisterDevice(serverDevice, displayName);
|
|
||||||
_server.Start().Wait();
|
|
||||||
|
|
||||||
// Set up a client ScanController for scanning through EsclScanDriver -> network -> ScanServer
|
|
||||||
_client = new ScanController(ScanningContext);
|
|
||||||
var uuid = new ScanServerDevice { Device = serverDevice, Name = displayName }.GetUuid(_server.InstanceId);
|
|
||||||
_clientDevice = new ScanDevice(Driver.Escl, uuid, displayName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Dispose()
|
|
||||||
{
|
|
||||||
_server.Stop().Wait();
|
|
||||||
base.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FindDevice()
|
public async Task FindDevice()
|
||||||
{
|
{
|
||||||
@ -212,4 +174,18 @@ public class ScanServerIntegrationTests : ContextualTests
|
|||||||
pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 2));
|
pageStartMock.Received()(Arg.Any<object>(), Arg.Is<PageStartEventArgs>(args => args.PageNumber == 2));
|
||||||
pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(args => args.PageNumber == 2 && args.Progress == 0.5));
|
pageProgressMock.Received()(Arg.Any<object>(), Arg.Is<PageProgressEventArgs>(args => args.PageNumber == 2 && args.Progress == 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanPreventedByHttpsSecurityPolicy()
|
||||||
|
{
|
||||||
|
var scanResult = _client.Scan(new ScanOptions
|
||||||
|
{
|
||||||
|
Device = _clientDevice,
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
SecurityPolicy = EsclSecurityPolicy.RequireHttps
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Assert.ThrowsAsync<EsclSecurityPolicyViolationException>(async () => await scanResult.ToListAsync());
|
||||||
|
}
|
||||||
}
|
}
|
53
NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs
Normal file
53
NAPS2.Sdk.Tests/Remoting/ScanServerTestsBase.cs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NAPS2.Escl;
|
||||||
|
using NAPS2.Escl.Server;
|
||||||
|
using NAPS2.Remoting.Server;
|
||||||
|
using NAPS2.Scan;
|
||||||
|
using NAPS2.Scan.Internal;
|
||||||
|
using NAPS2.Sdk.Tests.Mocks;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace NAPS2.Sdk.Tests.Remoting;
|
||||||
|
|
||||||
|
public class ScanServerTestsBase : ContextualTests
|
||||||
|
{
|
||||||
|
protected readonly ScanServer _server;
|
||||||
|
private protected readonly MockScanBridge _bridge;
|
||||||
|
protected readonly ScanController _client;
|
||||||
|
protected readonly ScanDevice _clientDevice;
|
||||||
|
|
||||||
|
public ScanServerTestsBase(ITestOutputHelper testOutputHelper,
|
||||||
|
EsclSecurityPolicy securityPolicy = EsclSecurityPolicy.None,
|
||||||
|
X509Certificate2 certificate = null) : base(testOutputHelper)
|
||||||
|
{
|
||||||
|
_server = new ScanServer(ScanningContext, new EsclServer());
|
||||||
|
|
||||||
|
// Set up a server connecting to a mock scan backend
|
||||||
|
_bridge = new MockScanBridge();
|
||||||
|
var scanBridgeFactory = Substitute.For<IScanBridgeFactory>();
|
||||||
|
scanBridgeFactory.Create(Arg.Any<ScanOptions>()).Returns(_bridge);
|
||||||
|
_server.ScanController = new ScanController(ScanningContext, scanBridgeFactory);
|
||||||
|
_server.SecurityPolicy = securityPolicy;
|
||||||
|
_server.Certificate = certificate;
|
||||||
|
|
||||||
|
// Initialize the server with a single device with a unique ID for the test
|
||||||
|
var displayName = $"testName-{Guid.NewGuid()}";
|
||||||
|
ScanningContext.Logger.LogDebug("Display name: {Name}", displayName);
|
||||||
|
var serverDevice = new ScanDevice(ScanOptionsValidator.SystemDefaultDriver, "testID", "testName");
|
||||||
|
_server.RegisterDevice(serverDevice, displayName);
|
||||||
|
_server.Start().Wait();
|
||||||
|
|
||||||
|
// Set up a client ScanController for scanning through EsclScanDriver -> network -> ScanServer
|
||||||
|
_client = new ScanController(ScanningContext);
|
||||||
|
var uuid = new ScanServerDevice { Device = serverDevice, Name = displayName }.GetUuid(_server.InstanceId);
|
||||||
|
_clientDevice = new ScanDevice(Driver.Escl, uuid, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
_server.Stop().Wait();
|
||||||
|
base.Dispose();
|
||||||
|
}
|
||||||
|
}
|
55
NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs
Normal file
55
NAPS2.Sdk.Tests/Remoting/TlsScanServerTests.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Security.Authentication;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using NAPS2.Escl;
|
||||||
|
using NAPS2.Scan;
|
||||||
|
using NAPS2.Sdk.Tests.Asserts;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace NAPS2.Sdk.Tests.Remoting;
|
||||||
|
|
||||||
|
public class TlsScanServerTests(ITestOutputHelper testOutputHelper) : ScanServerTestsBase(testOutputHelper,
|
||||||
|
EsclSecurityPolicy.RequireHttps, new X509Certificate2(BinaryResources.testcert))
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task FindDevice()
|
||||||
|
{
|
||||||
|
var devices = await _client.GetDeviceList(Driver.Escl);
|
||||||
|
// The device name is suffixed with the IP so we just check the prefix matches
|
||||||
|
Assert.Contains(devices,
|
||||||
|
device => device.Name.StartsWith(_clientDevice.Name) && device.ID == _clientDevice.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Scan()
|
||||||
|
{
|
||||||
|
_bridge.MockOutput = CreateScannedImages(ImageResources.dog);
|
||||||
|
var images = await _client.Scan(new ScanOptions
|
||||||
|
{
|
||||||
|
Device = _clientDevice,
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
SecurityPolicy = EsclSecurityPolicy.RequireHttps
|
||||||
|
}
|
||||||
|
}).ToListAsync();
|
||||||
|
Assert.Single(images);
|
||||||
|
ImageAsserts.Similar(ImageResources.dog, images[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanPreventedByTrustedCertificateSecurityPolicy()
|
||||||
|
{
|
||||||
|
var scanResult = _client.Scan(new ScanOptions
|
||||||
|
{
|
||||||
|
Device = _clientDevice,
|
||||||
|
EsclOptions =
|
||||||
|
{
|
||||||
|
SecurityPolicy = EsclSecurityPolicy.RequireTrustedCertificate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var exception = await Assert.ThrowsAsync<HttpRequestException>(async () => await scanResult.ToListAsync());
|
||||||
|
Assert.True(exception.InnerException is AuthenticationException ||
|
||||||
|
exception.InnerException?.InnerException is AuthenticationException);
|
||||||
|
}
|
||||||
|
}
|
19
NAPS2.Sdk.Tests/Resources/testcert.crt
Normal file
19
NAPS2.Sdk.Tests/Resources/testcert.crt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDEzCCAfsCFBbfet3dXy6Z774903bxJvhounivMA0GCSqGSIb3DQEBCwUAMEUx
|
||||||
|
CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
|
||||||
|
cm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjQwMzI3MDI1MDQxWhgPMjEyNDAzMDMw
|
||||||
|
MjUwNDFaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYD
|
||||||
|
VQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUA
|
||||||
|
A4IBDwAwggEKAoIBAQDBIPIafttfxb8cwSQEld5YRd7SX1AD14p6wBZ32qfY+NkG
|
||||||
|
/lKKhhDK2mFucO+6prhllYrutrXt9F8myyap7RbRGFLytZahgOLK9BajIUQCXKHA
|
||||||
|
6VHehCdZHVdMVTzZk+VGsgctvCxX1pIGWeiR42uiPu6T7FijGHZrpUNIMheAATi3
|
||||||
|
4LSVmDY+jxHRvBpDXVBdsoHzIrfYUA+GVGVpTpbzmQmooMH0c5bj3SNidiy7Mhx0
|
||||||
|
EuuwPSATQ6E2aG6ckhn9tjzTIJWA+3RvoUU9zqHkAj2J4+xXYl09TzqsRAwZ0w+r
|
||||||
|
JNMQ1hOKualIyMnnQP74a4skZKJg+D3+R6R9qjshAgMBAAEwDQYJKoZIhvcNAQEL
|
||||||
|
BQADggEBAA9nzTygpYaAbCBI+pfscOAnF2kKn8tAyCy7R2LbEa2zPFV+2ZJUCFZt
|
||||||
|
E47jvpzFVrhMbd1sgmxup2P3Reeff718YIMFB3HAEDXmCUHd+Jh2HnoUfcNQVoUv
|
||||||
|
HSIskPpWK0PueZxRbPA72uTBpEQcwZ06kPREMEmiKkoWh9db2tMpjdiF0ci8XZdg
|
||||||
|
2qCMgJUrTVw3wtIufSPu8LWklnHM8T2uHtNQlppxSE5a0Sa9IU12dTWaA96GCO+X
|
||||||
|
AdQm7PvVSdaocRKhrsnJ5pxtvJFSYuqP2bMxstagkfqPpOJYO6gp/efBq+vqfJg1
|
||||||
|
VTgLVJgjTFNwEdOytQJ9ZPrlpBjupyI=
|
||||||
|
-----END CERTIFICATE-----
|
16
NAPS2.Sdk.Tests/Resources/testcert.csr
Normal file
16
NAPS2.Sdk.Tests/Resources/testcert.csr
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-----BEGIN CERTIFICATE REQUEST-----
|
||||||
|
MIICijCCAXICAQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
|
||||||
|
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN
|
||||||
|
AQEBBQADggEPADCCAQoCggEBAMEg8hp+21/FvxzBJASV3lhF3tJfUAPXinrAFnfa
|
||||||
|
p9j42Qb+UoqGEMraYW5w77qmuGWViu62te30XybLJqntFtEYUvK1lqGA4sr0FqMh
|
||||||
|
RAJcocDpUd6EJ1kdV0xVPNmT5UayBy28LFfWkgZZ6JHja6I+7pPsWKMYdmulQ0gy
|
||||||
|
F4ABOLfgtJWYNj6PEdG8GkNdUF2ygfMit9hQD4ZUZWlOlvOZCaigwfRzluPdI2J2
|
||||||
|
LLsyHHQS67A9IBNDoTZobpySGf22PNMglYD7dG+hRT3OoeQCPYnj7FdiXT1POqxE
|
||||||
|
DBnTD6sk0xDWE4q5qUjIyedA/vhriyRkomD4Pf5HpH2qOyECAwEAAaAAMA0GCSqG
|
||||||
|
SIb3DQEBCwUAA4IBAQB7eR0vqyWCuf0EUSBYYngHfewJM/dBUR+C+ZRloEwYBkwU
|
||||||
|
ma06L/3uSV50+L81x2ZbOi93Ee6WrukdYMq0r82LlizHDAVeWz6FkuDCobVyWnbX
|
||||||
|
QvoUbvPAHvBmw172Zkzs7pGCbq3h0gejqzMOT6lVnZOMsHRXDVVvM7afatSNMf6w
|
||||||
|
EnIpbil4bQ9XQoj4bF1f81d28E9O4w4saB7WLDvbjukeQC81qRhXu7FXAsLP9ZA1
|
||||||
|
Pq0wuqCIMmfF6BVh9reZ8nVR9RtrFGSOT6+rVgztjuuFETq7p83xawdABQwYTE1M
|
||||||
|
icvlO9gXI1Gey4CkS9uTGjrH1JU5zLOHNL0RwDWe
|
||||||
|
-----END CERTIFICATE REQUEST-----
|
28
NAPS2.Sdk.Tests/Resources/testcert.key
Normal file
28
NAPS2.Sdk.Tests/Resources/testcert.key
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDBIPIafttfxb8c
|
||||||
|
wSQEld5YRd7SX1AD14p6wBZ32qfY+NkG/lKKhhDK2mFucO+6prhllYrutrXt9F8m
|
||||||
|
yyap7RbRGFLytZahgOLK9BajIUQCXKHA6VHehCdZHVdMVTzZk+VGsgctvCxX1pIG
|
||||||
|
WeiR42uiPu6T7FijGHZrpUNIMheAATi34LSVmDY+jxHRvBpDXVBdsoHzIrfYUA+G
|
||||||
|
VGVpTpbzmQmooMH0c5bj3SNidiy7Mhx0EuuwPSATQ6E2aG6ckhn9tjzTIJWA+3Rv
|
||||||
|
oUU9zqHkAj2J4+xXYl09TzqsRAwZ0w+rJNMQ1hOKualIyMnnQP74a4skZKJg+D3+
|
||||||
|
R6R9qjshAgMBAAECggEAEjYtHlqADVP0ZZ3A673GLcTI8kWSogodQN4EQGEaGte8
|
||||||
|
f3BUEEP8KWTWczerI4q9MLcdVs1b8ohswJe/mZ6F3EnS6Jg/EBO7TzAdQlzMsPxT
|
||||||
|
NIHL+pOzsi+WH9iZ2Fqd8ECxdJqeA9p0Aq1PxRIRAEe277QF17tiz1vSMGio1qUc
|
||||||
|
4c59mMXArApDNWLmphqsG6scQf5JyY8HHdjA/m4kfltiimlhId0Z7vJ7IuCndI7w
|
||||||
|
tr3kj5meJFqmmzl05U0a47WXiF4bpbZ/Io6Hk5Zu/40xgqBKVByPIv7nuwbJj5bA
|
||||||
|
ev0wd5+iVNErFKIw+xCI+wln0imQSuyQXtBjz3Cw0QKBgQDmaYbkVxLjmxRHHR/3
|
||||||
|
GmgET73QFmjlkL6L/truOfxbrLDBx8nDEp3vhq1u9+qou0ODYbU5dPdMgWNULpEH
|
||||||
|
M9aj+4LxeTOoAr2tZkVANiCJNeZDNfQt95puH822skXIz6Y8DLAfZ55DOIlmwhN/
|
||||||
|
4+gdGHeql5MCUwdW33VI1UcZWwKBgQDWk3jSwpt3WUrFavQez22AJtezeTPywyTK
|
||||||
|
FFI+Yo6W4tUF22ALgaN00yUDkAA8CZvc7/WAoGwyGbbBGw89Ct2WHq4bN+PQVo0c
|
||||||
|
U4WvxlAKLq5osFH2Mm0p9KkA/zDxAZZfOnjd4TgQbTe4bU3T/FM/LeWCSX0vMgb4
|
||||||
|
NnwWZ2nqMwKBgQDJjvKzeQBLHvQkKXQ3A2COtPsEtzXX7EDj0nPOBeeegni1a4Iy
|
||||||
|
JW0Hhbbd5f3e0MIEgkq4Envq7xznHT09IbnYBULM3gu0I4Gt2FMoErFvljjx/pa2
|
||||||
|
R21OfH/GHDkzq4Jt8WN4dXpar3By9b99Fu+L1EWKc8HkPKGk+yFsLzZdFQKBgQDU
|
||||||
|
VHvj+sTCli5CKnLFJjdR753UsCPynp4CBZfYucglkPKA6DMjT7ZCvUlMPCuvPUbp
|
||||||
|
mt3R2W0XKpDIh5FNsznP+i4JKwYYu/zIwfFxHYlIeicF2yxPtliFgt/V57AzXIHD
|
||||||
|
W+YMkXfb8WeI7Uhtc6ugwjbw9O2WTSfOaIPj25NYNwKBgQCRH/OqDClcO9IKkiUU
|
||||||
|
yl5XtXWLqobGsuSIivesQ2k8+83yLoYY8IaUr1IBYlQQAwvCRTvzXqQtkOL9uTi7
|
||||||
|
xzY6+wGphQvJjEweMggBbY4XRdfip7XdZU9CDDog1GyaUgfqR0zov6FLHVf0/EVA
|
||||||
|
4OrmR6DYSJhAZSgpahZGlZEuNw==
|
||||||
|
-----END PRIVATE KEY-----
|
BIN
NAPS2.Sdk.Tests/Resources/testcert.pfx
Normal file
BIN
NAPS2.Sdk.Tests/Resources/testcert.pfx
Normal file
Binary file not shown.
@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using NAPS2.Escl;
|
using NAPS2.Escl;
|
||||||
using NAPS2.Escl.Server;
|
using NAPS2.Escl.Server;
|
||||||
using NAPS2.Scan;
|
using NAPS2.Scan;
|
||||||
@ -22,21 +23,40 @@ public class ScanServer : IDisposable
|
|||||||
ScanController = new ScanController(scanningContext);
|
ScanController = new ScanController(scanningContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ScanController ScanController { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A unique ID that is used to help derive the UUIDs for shared scanners. If you expect to have multiple shared
|
/// A unique ID that is used to help derive the UUIDs for shared scanners. If you expect to have multiple shared
|
||||||
/// scanners with the same name/model on the same network it may be useful to set this to a unique value.
|
/// scanners with the same name/model on the same network it may be useful to set this to a unique value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid InstanceId { get; set; }
|
public Guid InstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The security policy to use for the ESCL server.
|
||||||
|
/// </summary>
|
||||||
|
public EsclSecurityPolicy SecurityPolicy
|
||||||
|
{
|
||||||
|
get => _esclServer.SecurityPolicy;
|
||||||
|
set => _esclServer.SecurityPolicy = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If non-null, enables TLS connections to the server with the given certificate.
|
||||||
|
/// </summary>
|
||||||
|
public X509Certificate2? Certificate
|
||||||
|
{
|
||||||
|
get => _esclServer.Certificate;
|
||||||
|
set => _esclServer.Certificate = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ScanController ScanController { get; set; }
|
||||||
|
|
||||||
public void SetDefaultIcon(IMemoryImage icon) =>
|
public void SetDefaultIcon(IMemoryImage icon) =>
|
||||||
SetDefaultIcon(icon.SaveToMemoryStream(ImageFileFormat.Png).ToArray());
|
SetDefaultIcon(icon.SaveToMemoryStream(ImageFileFormat.Png).ToArray());
|
||||||
|
|
||||||
public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng;
|
public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng;
|
||||||
|
|
||||||
public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0) =>
|
public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0, int tlsPort = 0) =>
|
||||||
RegisterDevice(new ScanServerDevice { Device = device, Name = displayName ?? device.Name, Port = port });
|
RegisterDevice(new ScanServerDevice
|
||||||
|
{ Device = device, Name = displayName ?? device.Name, Port = port, TlsPort = tlsPort });
|
||||||
|
|
||||||
private void RegisterDevice(ScanServerDevice sharedDevice)
|
private void RegisterDevice(ScanServerDevice sharedDevice)
|
||||||
{
|
{
|
||||||
@ -60,6 +80,7 @@ public class ScanServer : IDisposable
|
|||||||
return new EsclDeviceConfig
|
return new EsclDeviceConfig
|
||||||
{
|
{
|
||||||
Port = device.Port,
|
Port = device.Port,
|
||||||
|
TlsPort = device.TlsPort,
|
||||||
Capabilities = new EsclCapabilities
|
Capabilities = new EsclCapabilities
|
||||||
{
|
{
|
||||||
MakeAndModel = device.Name,
|
MakeAndModel = device.Name,
|
||||||
|
@ -9,6 +9,7 @@ internal record ScanServerDevice
|
|||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
public required ScanDevice Device { get; init; }
|
public required ScanDevice Device { get; init; }
|
||||||
public int Port { get; init; }
|
public int Port { get; init; }
|
||||||
|
public int TlsPort { get; init; }
|
||||||
|
|
||||||
public string GetUuid(Guid instanceId)
|
public string GetUuid(Guid instanceId)
|
||||||
{
|
{
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
namespace NAPS2.Scan;
|
using NAPS2.Escl;
|
||||||
|
|
||||||
|
namespace NAPS2.Scan;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scanning options specific to the ESCL driver.
|
/// Scanning options specific to the ESCL driver.
|
||||||
@ -9,4 +11,9 @@ public class EsclOptions
|
|||||||
/// The maximum time (in ms) to search for ESCL devices when calling GetDevices or at the start of a scan.
|
/// The maximum time (in ms) to search for ESCL devices when calling GetDevices or at the start of a scan.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SearchTimeout { get; set; } = 5000;
|
public int SearchTimeout { get; set; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The security policy to use for ESCL connections.
|
||||||
|
/// </summary>
|
||||||
|
public EsclSecurityPolicy SecurityPolicy { get; set; }
|
||||||
}
|
}
|
@ -46,6 +46,7 @@ internal class EsclScanDriver : IScanDriver
|
|||||||
var localIPsTask = options.ExcludeLocalIPs ? LocalIPsHelper.Get() : null;
|
var localIPsTask = options.ExcludeLocalIPs ? LocalIPsHelper.Get() : null;
|
||||||
using var locator = new EsclServiceLocator(service =>
|
using var locator = new EsclServiceLocator(service =>
|
||||||
{
|
{
|
||||||
|
// TODO: Consider limiting available devices by security policy
|
||||||
var ip = service.IpV4 ?? service.IpV6!;
|
var ip = service.IpV4 ?? service.IpV6!;
|
||||||
if (options.ExcludeLocalIPs && localIPsTask!.Result.Contains(ip.ToString()))
|
if (options.ExcludeLocalIPs && localIPsTask!.Result.Contains(ip.ToString()))
|
||||||
{
|
{
|
||||||
@ -83,6 +84,7 @@ internal class EsclScanDriver : IScanDriver
|
|||||||
|
|
||||||
var client = new EsclClient(service)
|
var client = new EsclClient(service)
|
||||||
{
|
{
|
||||||
|
SecurityPolicy = options.EsclOptions.SecurityPolicy,
|
||||||
Logger = _logger,
|
Logger = _logger,
|
||||||
CancelToken = cancelToken
|
CancelToken = cancelToken
|
||||||
};
|
};
|
||||||
|
@ -37,6 +37,8 @@
|
|||||||
<OcrDefaultMode>Fast</OcrDefaultMode>
|
<OcrDefaultMode>Fast</OcrDefaultMode>
|
||||||
<OcrDefaultAfterScanning>true</OcrDefaultAfterScanning>
|
<OcrDefaultAfterScanning>true</OcrDefaultAfterScanning>
|
||||||
<ForcePdfCompat>Default</ForcePdfCompat>
|
<ForcePdfCompat>Default</ForcePdfCompat>
|
||||||
|
<EsclSecurityPolicy>None</EsclSecurityPolicy>
|
||||||
|
<EsclServerCertificatePath></EsclServerCertificatePath>
|
||||||
<EventLogging>None</EventLogging>
|
<EventLogging>None</EventLogging>
|
||||||
<DefaultProfileSettings>
|
<DefaultProfileSettings>
|
||||||
<DriverName></DriverName>
|
<DriverName></DriverName>
|
||||||
|
Loading…
Reference in New Issue
Block a user