diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index b1546c3..677e9d5 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -78,6 +78,23 @@ implements `ILookupPlugin`: `Lookup` gives what searches its site for a name and first, each as a name and a version. The server does the asking; the PC compares. One installed plugin is asked, the first that is on. +### A check for a person + +A site may put a check before its pages that only a person in a browser can pass: a picture of characters to type, say. +A plugin never answers one, and Ferry gives it no way to. It says the check is there, by throwing a `StageException` +whose `Check` is a `PersonCheck`: the page that shows the check (https) and the name of the cookie the site sets once it +is passed. + +Where a person is, on the Browser page, Ferry opens that page as it is in a browser window of its own, reached through +the server, so the site sees the address its pages are then read from. The person passes the check; Ferry types nothing. +When the cookie is there the window closes. A plugin that implements `ICheckedPlugin` then hears of it (`Passed`: the +cookie's value and the browser's user agent), and whatever was asked is asked once more. Every such plugin hears of +every pass, so each keeps only those of its own sites, in memory. + +Where no person is (a watched album read while the PC is off, a file in the queue) the failure stands as the plugin +worded it, so the message should say what to do. It needs the server's SSH to pass connections on (`AllowTcpForwarding`, +which is on unless a host turned it off), and Microsoft Edge WebView2 on the PC. + Reference `Ferry.Core` with `Private="false"`, as the sample does. Ferry has its own, and a package doesn't carry one. Setting names start with `FERRY_`. `PluginApi.Version` is the contract's number; a package says which it was built diff --git a/src/Ferry.Agent/AgentCommands.cs b/src/Ferry.Agent/AgentCommands.cs index 74212a5..4daec4b 100644 --- a/src/Ferry.Agent/AgentCommands.cs +++ b/src/Ferry.Agent/AgentCommands.cs @@ -97,8 +97,8 @@ public static async Task RunAsync(AgentPaths paths, CancellationToken cance // at once, so a stop never finds the lock held and an older agent's pid in the file await File.WriteAllTextAsync(paths.PidFile, Environment.ProcessId.ToString(CultureInfo.InvariantCulture), cancel); #if SERVER_AGENT - var (plugins, browsing, lookup) = ServerPlugins.Load(paths); - await using var host = AgentHost.Start(paths, plugins: plugins, browsing: browsing, lookup: lookup); + var (plugins, browsing, lookup, passed) = ServerPlugins.Load(paths); + await using var host = AgentHost.Start(paths, plugins: plugins, browsing: browsing, lookup: lookup, passed: passed); #else await using var host = AgentHost.Start(paths); #endif diff --git a/src/Ferry.Agent/ServerPlugins.cs b/src/Ferry.Agent/ServerPlugins.cs index fb0c4e9..1846473 100644 --- a/src/Ferry.Agent/ServerPlugins.cs +++ b/src/Ferry.Agent/ServerPlugins.cs @@ -9,10 +9,12 @@ namespace Ferry.Agent; internal static class ServerPlugins { /// Load every package in the agent's plugins folder, and add those that can be to what the agent downloads with. - /// each package, loaded or not, for the PC to hear of; the first of them with a site to browse, and the first that looks titles up, if any - public static (List Plugins, AgentBrowsing? Browsing, AgentLookup? Lookup) Load(AgentPaths paths) + /// each package, loaded or not, for the PC to hear of; the first of them with a site to browse, and the first that looks + /// titles up, if any; and what tells those whose sites ask for a person's check that one was passed, if any + public static (List Plugins, AgentBrowsing? Browsing, AgentLookup? Lookup, Action? Passed) Load(AgentPaths paths) { var plugins = new List(); + Action? passed = null; AgentBrowsing? browsing = null; AgentLookup? lookup = null; foreach (var installed in InstalledPlugins.Load(paths.PluginsFolder, AgentHost.Log)) @@ -28,6 +30,7 @@ public static (List Plugins, AgentBrowsing? Browsing, AgentLookup? browsing = Browsing(plugin, site); else if (installed.Browsing is not null) AgentHost.Log($"plugin {installed.Id} has a site to browse, and {browsing!.Name}'s is browsed already: only one is, for now"); + passed += installed.Passed; if (installed.Lookup is { } make && lookup is null) lookup = new AgentLookup(plugin.Name, plugin.IsOn, make); else if (installed.Lookup is not null) @@ -36,7 +39,7 @@ public static (List Plugins, AgentBrowsing? Browsing, AgentLookup? } plugins.Add(new AgentPlugin(installed.Id, installed.Version, installed.Sha256, problem)); } - return (plugins, browsing, lookup); + return (plugins, browsing, lookup, passed); } private static AgentBrowsing Browsing(SitePlugin plugin, SiteBrowsing site) => diff --git a/src/Ferry.App/Services/AppServices.cs b/src/Ferry.App/Services/AppServices.cs index 4d84b78..2d21d6b 100644 --- a/src/Ferry.App/Services/AppServices.cs +++ b/src/Ferry.App/Services/AppServices.cs @@ -40,6 +40,7 @@ private AppServices(DispatcherQueue ui, AppPaths paths, IReadOnlyDictionaryThe page reader and album watcher, for the Browser page, while an installed plugin has a site to browse. public BrowseHost Browse { get; } + /// Shows a person a site's check that only a person can pass, when the browsed site asks for one. + public PersonChecks Checks { get; } + /// Which of the browsed site's files were downloaded here, or null when that can't be kept. public Ferry.Core.Browse.DownloadedFiles? Downloaded { get; private set; } diff --git a/src/Ferry.App/Services/BrowseHost.cs b/src/Ferry.App/Services/BrowseHost.cs index caf7b1c..06a58e9 100644 --- a/src/Ferry.App/Services/BrowseHost.cs +++ b/src/Ferry.App/Services/BrowseHost.cs @@ -218,7 +218,7 @@ private async Task UseRemoteAsync(RemoteQueue? remote) if (_remote is { } previous) previous.ConnectionChanged -= OnConnectionChanged; _remote = remote; - _remoteBrowser = remote is null ? null : new RemotePageBrowser(remote); + _remoteBrowser = remote is null ? null : new RemotePageBrowser(remote, _app.Checks.PassAsync); _remoteWatcher = remote is null ? null : new RemoteAlbumWatcher(remote); if (remote is null) return; diff --git a/src/Ferry.App/Services/PersonChecks.cs b/src/Ferry.App/Services/PersonChecks.cs new file mode 100644 index 0000000..313e387 --- /dev/null +++ b/src/Ferry.App/Services/PersonChecks.cs @@ -0,0 +1,189 @@ +using System.Runtime.InteropServices; +using Ferry.Core; +using Ferry.Core.Http; +using Ferry.Plugins.Server; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Web.WebView2.Core; + +namespace Ferry.App.Services; + +/// +/// A site's check that only a person can pass (), shown to the person: the site's own page in a +/// browser window, reached through the server so the site sees the address its pages are then read from. Ferry types +/// nothing and answers nothing. When the site has set its cookie, the window closes and the cookie goes to the server. +/// One window at a time: whoever else waited for the same check gets the same answer. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1001", Justification = "It lives as long as the session, as the hosts beside it do.")] +public sealed class PersonChecks(AppServices app, DispatcherQueue ui) +{ + // a check given up on isn't shown again for this long: an album's pictures would each ask + private static readonly TimeSpan LeaveAlone = TimeSpan.FromSeconds(30); + + private readonly SemaphoreSlim _oneAtATime = new(1, 1); + + // the last answer per site and cookie, with when it came; used inside the gate only + private readonly Dictionary _last = new(StringComparer.OrdinalIgnoreCase); + + /// What passing earned, or null when the person closed the window, or it can't be shown. + /// the server can't be reached for it, or doesn't pass connections on () + public async Task PassAsync(PersonCheck check, CancellationToken cancel) + { + if (app.IsDecoy || !check.IsUsable) + return null; + var asked = Environment.TickCount64; + await _oneAtATime.WaitAsync(cancel); + try + { + var key = $"{check.Page.Host}\n{check.Cookie}"; + if (_last.TryGetValue(key, out var last) + && (last.At >= asked || (last.Pass is null && Environment.TickCount64 - last.At < LeaveAlone.TotalMilliseconds))) + return last.Pass; + PassedCheck? pass = null; + try + { + pass = await ShowAsync(check, cancel); + } + finally + { + _last[key] = (pass, Environment.TickCount64); + } + return pass; + } + finally + { + _oneAtATime.Release(); + } + } + + private async Task ShowAsync(PersonCheck check, CancellationToken cancel) + { + await using var connection = await ServerConnection.OpenAsync(app.Settings, app.Secrets, cancel); + await using var tunnel = await connection.OpenTunnelAsync(check.Page.Host, cancel); + var where = app.Queue.ServerDisplay ?? "your server"; + return await OnUiAsync(() => CheckWindow.ShowAsync(check, tunnel.Port, where, cancel)); + } + + private Task OnUiAsync(Func> work) + { + if (ui.HasThreadAccess) + return work(); + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!ui.TryEnqueue(async () => + { + try + { + done.TrySetResult(await work()); + } + catch (OperationCanceledException e) + { + done.TrySetCanceled(e.CancellationToken); + } +#pragma warning disable CA1031 // whatever it is goes to whoever asked, not to the dispatcher + catch (Exception e) +#pragma warning restore CA1031 + { + done.TrySetException(e); + } + })) + done.TrySetException(new StageException(Stage.Server, "Ferry is closing, so the check can't be shown.")); + return done.Task; + } +} + +/// The window a check is shown in: the site's page and a line saying what it is. It has its own browser profile, removed after. +internal static class CheckWindow +{ + public static async Task ShowAsync(PersonCheck check, int proxyPort, string where, CancellationToken cancel) + { + var profile = Directory.CreateTempSubdirectory("ferry_check_").FullName; + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var view = new WebView2(); + var note = new TextBlock + { + Text = $"{check.Page.Host} wants to know a person is there. This is its own page, opened through {where}. " + + "Ferry carries on by itself once you have passed; close the window to give up.", + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(16, 12, 16, 12), + }; + var layout = new Grid { RowDefinitions = { new RowDefinition { Height = GridLength.Auto }, new RowDefinition() } }; + layout.Children.Add(note); + Grid.SetRow(view, 1); + layout.Children.Add(view); + var window = new Window { Title = $"{check.Page.Host}: a check for a person", Content = layout }; + window.AppWindow.Resize(new Windows.Graphics.SizeInt32(560, 680)); + var closed = false; + window.Closed += (_, _) => + { + closed = true; + done.TrySetResult(null); + }; + await using var stop = cancel.Register(() => done.TrySetCanceled(cancel)); + try + { + // socks5, not socks4: the name is looked up by the server, as it will be when the pages are read there + var options = new CoreWebView2EnvironmentOptions + { + AdditionalBrowserArguments = string.Create(System.Globalization.CultureInfo.InvariantCulture, $"--proxy-server=socks5://127.0.0.1:{proxyPort}"), + }; + var environment = await CoreWebView2Environment.CreateWithOptionsAsync("", profile, options); + window.Activate(); + await view.EnsureCoreWebView2Async(environment); + var core = view.CoreWebView2; + core.Settings.AreDevToolsEnabled = false; + core.Settings.IsPasswordAutosaveEnabled = false; + core.Settings.IsGeneralAutofillEnabled = false; + // the window is for this one site's check: nothing else opens in it + core.NewWindowRequested += (_, e) => e.Handled = true; + core.NavigationStarting += (_, e) => + e.Cancel = !Uri.TryCreate(e.Uri, UriKind.Absolute, out var to) || !HttpFetcher.IsSameSite(to, check.Page); + core.NavigationCompleted += async (_, _) => + { + try + { + // the site's page reloads itself once it has set its cookie: that is how a pass is seen + var cookies = await core.CookieManager.GetCookiesAsync(check.Page.AbsoluteUri); + if (cookies.FirstOrDefault(cookie => cookie.Name == check.Cookie) is { Value.Length: > 0 } cookie) + done.TrySetResult(new PassedCheck(check.Page, check.Cookie, cookie.Value, core.Settings.UserAgent)); + } + catch (Exception e) when (e is COMException or InvalidOperationException or ObjectDisposedException) + { + // the window closed meanwhile + } + }; + core.Navigate(check.Page.AbsoluteUri); + return await done.Task; + } + catch (Exception e) when (e is COMException or FileNotFoundException or InvalidOperationException) + { + throw new StageException(Stage.Server, + $"The check of {check.Page.Host} couldn't be shown: {e.Message} It needs Microsoft Edge WebView2, which comes with Windows 11.", inner: e); + } + finally + { + view.Close(); + if (!closed) + window.Close(); + _ = RemoveAsync(profile); + } + } + + /// The browser lets go of its profile a moment after its window: tried a few times, then left to Windows' own clearing of temp. + private static async Task RemoveAsync(string profile) + { + for (var tries = 0; tries < 5; tries++) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + try + { + Directory.Delete(profile, recursive: true); + return; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // still held + } + } + } +} diff --git a/src/Ferry.Core/PersonCheck.cs b/src/Ferry.Core/PersonCheck.cs new file mode 100644 index 0000000..aae5fd6 --- /dev/null +++ b/src/Ferry.Core/PersonCheck.cs @@ -0,0 +1,20 @@ +namespace Ferry.Core; + +/// +/// A check a site put in the way that only a person in a browser can pass: a picture of characters to type, say. Ferry +/// never answers one itself. It shows the person the site's own page, and what the site then gives their browser is used. +/// +/// the page that shows the check, https only; it is opened as it is +/// the name of the cookie the site sets on 's host once the check is passed +public sealed record PersonCheck(Uri Page, string Cookie) +{ + /// Whether Ferry will show it: a web page over https, and a cookie with a name. + public bool IsUsable => Page is { IsAbsoluteUri: true } && Page.Scheme == Uri.UriSchemeHttps && Cookie.Length is > 0 and <= 128; +} + +/// A a person passed. +/// the check's page +/// the cookie's name, as the check gave it +/// what the site set it to +/// the browser that passed: a site may take the cookie from no other +public sealed record PassedCheck(Uri Page, string Cookie, string Value, string UserAgent); diff --git a/src/Ferry.Core/Plugins/IFerryPlugin.cs b/src/Ferry.Core/Plugins/IFerryPlugin.cs index 379cee3..ba68aef 100644 --- a/src/Ferry.Core/Plugins/IFerryPlugin.cs +++ b/src/Ferry.Core/Plugins/IFerryPlugin.cs @@ -48,6 +48,20 @@ public interface ILookupPlugin : IFerryPlugin ITitleLookup Lookup(PluginContext context); } +/// +/// A plugin whose site may ask for a check only a person can pass. It says so by throwing a +/// with its set; when a person at the PC has passed it, this hears of it, and whoever +/// asked asks once more. +/// +public interface ICheckedPlugin : IFerryPlugin +{ + /// + /// A person passed a check. Every plugin of this kind hears of every pass, so each takes only those of its own + /// sites. It is kept in memory only. + /// + void Passed(PassedCheck pass); +} + /// Searches a site for a title. public interface ITitleLookup { diff --git a/src/Ferry.Core/StageException.cs b/src/Ferry.Core/StageException.cs index 4ec0d72..ee1b08a 100644 --- a/src/Ferry.Core/StageException.cs +++ b/src/Ferry.Core/StageException.cs @@ -61,6 +61,12 @@ public sealed class StageException(Stage stage, string message, bool isRetryable public TimeSpan? RetryAfter { get; } = retryAfter; public HttpStatusCode? Status { get; } = status; + /// + /// The site answered with a check only a person in a browser can pass, and this is it. Where there is a person to + /// ask, Ferry shows them the page and hands what they earned to the plugin (). + /// + public PersonCheck? Check { get; init; } + /// The server is overloaded or limiting how often it is asked (503, 429): fewer requests, more slowly. public bool IsBusy => Status is HttpStatusCode.ServiceUnavailable or HttpStatusCode.TooManyRequests; } diff --git a/src/Ferry.Plugins.Host/InstalledPlugins.cs b/src/Ferry.Plugins.Host/InstalledPlugins.cs index 44f424d..0b2fcbb 100644 --- a/src/Ferry.Plugins.Host/InstalledPlugins.cs +++ b/src/Ferry.Plugins.Host/InstalledPlugins.cs @@ -15,8 +15,9 @@ namespace Ferry.Plugins.Host; /// why it couldn't be loaded, for the person who installed it /// its site to browse, when it has one /// what searches its site for titles, when it does that; a throw inside it is said of the plugin +/// what hears of a check a person passed, when its site may ask for one; a throw inside it is logged public sealed record InstalledPlugin(string Id, string Version, string Sha256, SitePlugin? Plugin, string? Problem, SiteBrowsing? Browsing = null, - Func? Lookup = null); + Func? Lookup = null, Action? Passed = null); /// A plugin's site as the Browser page needs it: which links are its pages, and what reads them. /// what reads the site, with the settings in the context; a throw inside it is said of the plugin @@ -78,7 +79,8 @@ private static InstalledPlugin Load(string file, string unpacked, List new GuardedBrowsing(browsing.Browse(context), plugin.Name)); Func? lookup = loaded is ILookupPlugin looks ? context => new GuardedLookup(looks.Lookup(context), plugin.Name) : null; - return new InstalledPlugin(manifest.Id, manifest.Version, package.Sha256, Guarded(plugin, log), null, site, lookup); + Action? passed = loaded is ICheckedPlugin checks ? pass => Hear(plugin.Name, log, () => checks.Passed(pass)) : null; + return new InstalledPlugin(manifest.Id, manifest.Version, package.Sha256, Guarded(plugin, log), null, site, lookup, passed); } #pragma warning disable CA1031 // a stranger's code, run for the first time: whatever it throws is its own failure catch (Exception e) @@ -119,6 +121,20 @@ private static void Forget(string unpacked, List plugins) Secrets = plugin.Secrets, }; + private static void Hear(string plugin, Action log, Action hear) + { + try + { + hear(); + } +#pragma warning disable CA1031 // as above + catch (Exception e) +#pragma warning restore CA1031 + { + log($"plugin {plugin} failed hearing of a passed check: {e.Message}"); + } + } + private static List Try(string plugin, Action log, Func> make) { try diff --git a/src/Ferry.Plugins.Server/AgentHost.cs b/src/Ferry.Plugins.Server/AgentHost.cs index 10ec86c..da60459 100644 --- a/src/Ferry.Plugins.Server/AgentHost.cs +++ b/src/Ferry.Plugins.Server/AgentHost.cs @@ -89,6 +89,7 @@ public sealed class AgentHost : IAsyncDisposable private readonly IReadOnlyList _plugins; private readonly AgentBrowsing? _browsing; private readonly AgentLookup? _lookup; + private readonly Action? _passed; private readonly AlbumWatcher _watcher; private readonly HttpFetcher _http; private readonly SpeedLimit _overall = new(); @@ -119,9 +120,10 @@ public sealed class AgentHost : IAsyncDisposable private volatile bool _unsaved; private int _saveScheduled; - private AgentHost(AgentPaths paths, string version, FerrySettings settings, IReadOnlyList plugins, AgentBrowsing? browsing, AgentLookup? lookup) + private AgentHost(AgentPaths paths, string version, FerrySettings settings, IReadOnlyList plugins, AgentBrowsing? browsing, AgentLookup? lookup, + Action? passed) { - (_paths, _version, _plugins, _browsing, _lookup, _settings) = (paths, version, plugins, browsing, lookup, settings); + (_paths, _version, _plugins, _browsing, _lookup, _passed, _settings) = (paths, version, plugins, browsing, lookup, passed, settings); // the watcher reads through whatever reads the site now, and holds off while nothing does _watcher = new AlbumWatcher(new SitePages(this), paths.WatchedFile, IAlbumWatcher.Every, TimeProvider.System, mayRead: () => _site is not null, problem: (what, e) => Log($"{what}: {e.Message}")); @@ -138,12 +140,13 @@ private AgentHost(AgentPaths paths, string version, FerrySettings settings, IRea /// the installed plugins, loaded or not, already added to what the agent downloads with /// the plugin whose site is browsed, when one of them has a site to browse /// the plugin whose site is searched for titles, when one of them does that + /// tells the plugins whose sites ask for a person's check that one was passed, when any does public static AgentHost Start(AgentPaths paths, string? version = null, IReadOnlyList? plugins = null, AgentBrowsing? browsing = null, - AgentLookup? lookup = null) + AgentLookup? lookup = null, Action? passed = null) { CreatePrivateFolder(paths.Root); var settings = ReadSettings(paths); - var host = new AgentHost(paths, version ?? Product.Build, Build(paths, settings), plugins ?? [], browsing, lookup); + var host = new AgentHost(paths, version ?? Product.Build, Build(paths, settings), plugins ?? [], browsing, lookup, passed); host.Apply(settings); s_logFile = paths.LogFile; // the request details are kept in memory only: a PC that connects sends them again (AgentCommand.Details) @@ -336,7 +339,8 @@ private async Task AnswerAsync(Session session, AgentRequest request) } catch (StageException e) { - answer = new AgentMessage(Answers: request.Id, Error: e.Message, ErrorStage: e.Stage); + // a check Ferry wouldn't show is left out: the failure stands without it + answer = new AgentMessage(Answers: request.Id, Error: e.Message, ErrorStage: e.Stage, Check: e.Check is { IsUsable: true } check ? check : null); } #pragma warning disable CA1031 // the PC waits for an answer: it gets one, with what went wrong, and the agent goes on catch (Exception e) @@ -471,6 +475,14 @@ await _queue.SetDetailsAsync(Item(request).Id, request.Details is { IsEmpty: fal ? $"{lookup.Name} is off, so nothing is looked up." : "No plugin on the server looks titles up."); return new AgentMessage(Titles: [.. await titles.SearchAsync(request.Name ?? "", cancel)]); + case AgentCommand.Passed: + if (request.Pass is not { Value.Length: > 0 } pass || !new PersonCheck(pass.Page, pass.Cookie).IsUsable) + throw new InvalidOperationException("No passed check was sent."); + if (_passed is null) + throw new InvalidOperationException("No plugin on the server asks for a person's check."); + _passed(pass); + Log($"a person passed the check of {pass.Page.Host}"); + return ok; default: throw new InvalidOperationException($"The agent doesn't know \"{request.Command}\"."); } diff --git a/src/Ferry.Plugins.Server/AgentProtocol.cs b/src/Ferry.Plugins.Server/AgentProtocol.cs index 54070a9..548aa08 100644 --- a/src/Ferry.Plugins.Server/AgentProtocol.cs +++ b/src/Ferry.Plugins.Server/AgentProtocol.cs @@ -94,6 +94,12 @@ public enum AgentCommand /// Search the site a plugin knows for a title (), and answer with what it lists (). TitleSearch, + + /// + /// A person at the PC passed a site's check (), which an answer before had said was in + /// the way (): the plugins hear of it, and the PC asks again. + /// + Passed, } public enum AgentEventKind @@ -117,11 +123,13 @@ public enum AgentEventKind /// Add: a folder of their own for the files, under the download folder (an album's name) /// Add, Details, Expand, Read: what to send with every request for these files; kept in the agent's memory only /// Add: what the finished file must hash to +/// Passed: the check and what passing it earned; kept in the agent's memory only internal sealed record AgentRequest(int Id, AgentCommand Command, Guid? Item = null, List? Lines = null, string? Name = null, long? BytesPerSecond = null, bool? Flag = null, string? Version = null, Dictionary? Settings = null, string? Link = null, string? Folder = null, RequestDetails? Details = null, string? Sha256 = null, - BrowsedPage? Page = null, AlbumQuery? Query = null, Dictionary? Secrets = null); + BrowsedPage? Page = null, AlbumQuery? Query = null, Dictionary? Secrets = null, + PassedCheck? Pass = null); /// /// One line from the agent to the PC: the answer to request , or an event @@ -140,6 +148,7 @@ internal sealed record AgentRequest(int Id, AgentCommand Command, Guid? Item = n /// Expand: the files behind the link; null when it is a link to one file /// Read: the file's name as its site gives it /// FileInfo: the finished file's size. Read: the file's size, when its site says it before downloading +/// with : the site wants a check only a person can pass, and this is it internal sealed record AgentMessage(int? Answers = null, string? Error = null, Stage? ErrorStage = null, string? Version = null, List? Items = null, List? Duplicates = null, List? Rejected = null, @@ -148,7 +157,7 @@ internal sealed record AgentMessage(int? Answers = null, string? Error = null, S AgentStats? Stats = null, string? Log = null, string? Folder = null, FileList? Files = null, string? Name = null, List? Plugins = null, BrowsedPage? Page = null, List? Albums = null, List? Cards = null, byte[]? Picture = null, - AlbumDetails? Details = null, List? Titles = null); + AlbumDetails? Details = null, List? Titles = null, PersonCheck? Check = null); /// The one plugin whose site the agent browses, for the Browser page: its pages, its list of albums, and albums watched. /// what people call the plugin diff --git a/src/Ferry.Plugins.Server/RemoteBrowse.cs b/src/Ferry.Plugins.Server/RemoteBrowse.cs index 9daadfb..001f2c5 100644 --- a/src/Ferry.Plugins.Server/RemoteBrowse.cs +++ b/src/Ferry.Plugins.Server/RemoteBrowse.cs @@ -4,7 +4,11 @@ namespace Ferry.Plugins.Server; /// The PC's reader: the agent reads the page, or the list of albums, on the server and says what is on it. -public sealed class RemotePageBrowser(RemoteQueue agent) : IPageBrowser, IAlbumDirectory +/// +/// shows a person a site's check and returns what passing it earned, or null when it wasn't passed; without it a check +/// is a failure like any other +/// +public sealed class RemotePageBrowser(RemoteQueue agent, Func>? pass = null) : IPageBrowser, IAlbumDirectory { // an album of many pages, each read with retries, takes longer than an ordinary request may private static readonly TimeSpan ReadTimeout = TimeSpan.FromMinutes(10); @@ -25,10 +29,24 @@ public async Task DetailsAsync(PageLink album, CancellationToken c (await ReadAsync(new AgentRequest(0, AgentCommand.AlbumDetails, Link: album.Original.ToString()), cancel)).Details ?? throw new StageException(Stage.Server, "The server answered without the album's details."); + /// + /// Ask the agent. A site's check in the way is shown to the person once; passed, the agent hears of it and is asked + /// again. A check still in the way after that is the failure it says it is. + /// private async Task ReadAsync(AgentRequest request, CancellationToken cancel) { try { + try + { + return await agent.RequestAsync(request, ReadTimeout, cancel); + } + catch (StageException e) when (e.Check is { IsUsable: true } check && pass is not null) + { + if (await pass(check, cancel) is not { } passed) + throw; + await agent.RequestAsync(new AgentRequest(0, AgentCommand.Passed, Pass: passed), cancel: cancel); + } return await agent.RequestAsync(request, ReadTimeout, cancel); } catch (InvalidOperationException e) diff --git a/src/Ferry.Plugins.Server/RemoteQueue.cs b/src/Ferry.Plugins.Server/RemoteQueue.cs index 3485e12..e52dc81 100644 --- a/src/Ferry.Plugins.Server/RemoteQueue.cs +++ b/src/Ferry.Plugins.Server/RemoteQueue.cs @@ -466,7 +466,7 @@ internal async Task RequestAsync(AgentRequest request, TimeSpan? t return answer.Error switch { null => answer, - var error when answer.ErrorStage is { } stage => throw new StageException(stage, error), + var error when answer.ErrorStage is { } stage => throw new StageException(stage, error) { Check = answer.Check }, var error => throw new InvalidOperationException(error), }; } diff --git a/src/Ferry.Plugins.Server/ServerConnection.cs b/src/Ferry.Plugins.Server/ServerConnection.cs index 8c014f7..b695719 100644 --- a/src/Ferry.Plugins.Server/ServerConnection.cs +++ b/src/Ferry.Plugins.Server/ServerConnection.cs @@ -149,6 +149,48 @@ public AgentChannel OpenChannel(string command) } } + /// + /// Open a way for a browser on this PC to reach a site from the server's address: a SOCKS proxy on this PC's + /// loopback, there for as long as what is returned is kept. It is checked by reaching through it. + /// + /// the site the browser is for + /// the server can't be reached, or doesn't forward connections () + /// the server's key is not the confirmed one + public async Task OpenTunnelAsync(string host, CancellationToken cancel) + { + var port = ServerTunnel.FreePort(); + var askpass = _secret is null ? null : Askpass.Start(_secret); + SshProcess? ssh = null; + try + { + ssh = SshProcess.Start(_tools.Ssh, SshArguments.For(_target, _login, "", askpass: _secret is not null, socksPort: port), askpass?.Environment); + var tunnel = new ServerTunnel(ssh, askpass, port); + if (!await tunnel.ListensAsync(cancel)) + { + await ssh.ErrorsEndedAsync(cancel); + throw await FailureAsync(ssh.Errors, cancel); + } + if (!await ServerTunnel.ReachesAsync(port, host, cancel)) + { + throw new StageException(Stage.Server, + $"{Display} doesn't pass connections on for Ferry (its SSH has forwarding turned off: AllowTcpForwarding), " + + $"or it can't reach {host}. The check has to be passed from the server's address, so it can't be shown."); + } + return tunnel; + } + catch + { + if (ssh is not null) + { + ssh.Kill(); + await ssh.DisposeAsync(); + } + if (askpass is not null) + await askpass.DisposeAsync(); + throw; + } + } + /// Start for its stdout alone (a file's bytes), its stdin closed. internal (SshProcess Process, Askpass? Askpass) StartReading(string command) { diff --git a/src/Ferry.Plugins.Server/ServerTunnel.cs b/src/Ferry.Plugins.Server/ServerTunnel.cs new file mode 100644 index 0000000..4ae74a0 --- /dev/null +++ b/src/Ferry.Plugins.Server/ServerTunnel.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Ferry.Plugins.Server; + +/// +/// A SOCKS proxy on this PC's loopback whose connections leave from the server: one run of ssh with a dynamic forward +/// and no command (). It is for the browser window a person passes a +/// site's check in, which the site must see coming from the server, and it is open only while that window is. +/// Anything on this PC could use the port meanwhile, as it could any proxy on loopback. +/// +public sealed class ServerTunnel : IAsyncDisposable +{ + private static readonly TimeSpan ListenWait = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ReachWait = TimeSpan.FromSeconds(20); + private static readonly TimeSpan PollEvery = TimeSpan.FromMilliseconds(150); + private readonly SshProcess _ssh; + private readonly Askpass? _askpass; + + internal ServerTunnel(SshProcess ssh, Askpass? askpass, int port) => (_ssh, _askpass, Port) = (ssh, askpass, port); + + /// The proxy's port on 127.0.0.1. + public int Port { get; } + + /// A port on loopback nothing listens on right now. + internal static int FreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// Wait until ssh has logged in and listens; false when it ended first (its stderr says why) or took too long. + internal async Task ListensAsync(CancellationToken cancel) + { + using var wait = CancellationTokenSource.CreateLinkedTokenSource(cancel); + wait.CancelAfter(ListenWait); + try + { + while (!_ssh.Exited.IsCompleted) + { + using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + await probe.ConnectAsync(IPAddress.Loopback, Port, wait.Token); + return true; + } + catch (SocketException) + { + await Task.Delay(PollEvery, wait.Token); // not yet: it is still logging in + } + } + return false; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + return false; + } + } + + /// + /// Whether the proxy at opens a connection to 's https port: one + /// SOCKS5 request, by name, so the server looks the name up as it will for the browser. + /// + internal static async Task ReachesAsync(int port, string host, CancellationToken cancel) + { + var name = Encoding.ASCII.GetBytes(host); + if (name.Length is 0 or > 255) + return false; + using var wait = CancellationTokenSource.CreateLinkedTokenSource(cancel); + wait.CancelAfter(ReachWait); + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(IPAddress.Loopback, port, wait.Token); + await using var stream = new NetworkStream(socket, ownsSocket: false); + await stream.WriteAsync(new byte[] { 5, 1, 0 }, wait.Token); // version 5, one method: no login + var answer = new byte[4]; + await stream.ReadExactlyAsync(answer.AsMemory(0, 2), wait.Token); + if (answer[0] != 5 || answer[1] != 0) + return false; + // connect, to a name, port 443 + byte[] request = [5, 1, 0, 3, (byte)name.Length, .. name, 443 >> 8, 443 & 0xFF]; + await stream.WriteAsync(request, wait.Token); + await stream.ReadExactlyAsync(answer, wait.Token); + return answer[0] == 5 && answer[1] == 0; + } + catch (Exception e) when (e is SocketException or IOException or EndOfStreamException) + { + return false; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + return false; + } + } + + /// Close the proxy: ssh is killed, as it has nothing to finish. + public async ValueTask DisposeAsync() + { + _ssh.Kill(); + await _ssh.DisposeAsync(); + if (_askpass is not null) + await _askpass.DisposeAsync(); + } +} diff --git a/src/Ferry.Plugins.Server/SshArguments.cs b/src/Ferry.Plugins.Server/SshArguments.cs index 80614c6..cdb1dfb 100644 --- a/src/Ferry.Plugins.Server/SshArguments.cs +++ b/src/Ferry.Plugins.Server/SshArguments.cs @@ -42,7 +42,11 @@ internal static class SshArguments /// the remote command, already quoted for the server's shell () /// a key login whose passphrase askpass answers: BatchMode would stop ssh asking for it - public static List For(SshTarget target, SshLogin login, string command, bool askpass = false) + /// + /// no command at all: a SOCKS proxy on this PC's loopback at this port instead, whose connections leave from the + /// server (). The one run that forwards anything + /// + public static List For(SshTarget target, SshLogin login, string command, bool askpass = false, int? socksPort = null) { var arguments = new List { "-F", "none" }; void Option(string name, string value) => arguments.AddRange(["-o", $"{name}={value}"]); @@ -95,12 +99,19 @@ public static List For(SshTarget target, SshLogin login, string command, Option("Compression", "no"); Option("IPQoS", "throughput"); Option("ControlMaster", "no"); - Option("ClearAllForwardings", "yes"); + // it would clear the one forwarding a tunnel is for, given on the command line; with -F none there is no other + if (socksPort is null) + Option("ClearAllForwardings", "yes"); + else + Option("ExitOnForwardFailure", "yes"); Option("PermitLocalCommand", "no"); Option("LogLevel", "ERROR"); // no tty, no X11, no agent forwarding, and no escape character: stdin may be a binary file (an upload) arguments.AddRange(["-T", "-x", "-a", "-e", "none"]); - arguments.AddRange([target.Host, "--", command]); + if (socksPort is { } port) + arguments.AddRange(["-N", "-D", string.Create(CultureInfo.InvariantCulture, $"127.0.0.1:{port}"), target.Host]); + else + arguments.AddRange([target.Host, "--", command]); return arguments; } diff --git a/tests/Ferry.Core.Tests/OpenSshTests.cs b/tests/Ferry.Core.Tests/OpenSshTests.cs index 8de843b..b17020e 100644 --- a/tests/Ferry.Core.Tests/OpenSshTests.cs +++ b/tests/Ferry.Core.Tests/OpenSshTests.cs @@ -63,6 +63,21 @@ public void EveryRunIgnoresTheUsersConfigAndEndsWithTheCommand() Assert.Equal("none", After(arguments, "-e")); } + [Fact] + public void ATunnelRunsNoCommandAndForwardsOnlyItsOwnPortOnLoopback() + { + var arguments = SshArguments.For(Target, SshLogin.Key, "", socksPort: 51234); + var options = Options(arguments); + + Assert.Equal(["-N", "-D", "127.0.0.1:51234", "server.example"], arguments.TakeLast(4)); + Assert.DoesNotContain("--", arguments); + Assert.False(options.ContainsKey("ClearAllForwardings")); // it would clear the tunnel's own + Assert.Equal("yes", options["ExitOnForwardFailure"]); + Assert.Equal("yes", options["StrictHostKeyChecking"]); + // every other run still forwards nothing + Assert.Equal("yes", Options(SshArguments.For(Target, SshLogin.Key, "true"))["ClearAllForwardings"]); + } + [Fact] public void AKeyLoginNeverAsksAndChecksTheConfirmedKey() { diff --git a/tests/Ferry.Core.Tests/RemoteQueueTests.cs b/tests/Ferry.Core.Tests/RemoteQueueTests.cs index 19e9d55..460a8a9 100644 --- a/tests/Ferry.Core.Tests/RemoteQueueTests.cs +++ b/tests/Ferry.Core.Tests/RemoteQueueTests.cs @@ -987,4 +987,94 @@ public async Task AgentListensOnAUnixSocket() Directory.Delete(shortRoot, recursive: true); } } + + /// A site that answers with a check until a person has passed it, as a plugin's would. + private sealed class CheckedSite : Ferry.Core.Plugins.ISiteBrowsing + { + public static readonly PersonCheck Check = new(new Uri("https://albums.example/"), "pass"); + + public PassedCheck? Pass; + public int Asked; + + public Task> AlbumsAsync(Ferry.Core.Browse.AlbumQuery query, CancellationToken cancel) + { + Asked++; + return Pass is null + ? throw new StageException(Stage.Resolve, "albums.example wants a person.") { Check = Check } + : Task.FromResult>([]); + } + + public Task OpenAsync(Ferry.Core.Browse.PageLink link, CancellationToken cancel) => throw new NotSupportedException(); + + public Task ThumbnailAsync(Uri picture, CancellationToken cancel) => throw new NotSupportedException(); + + public Task DetailsAsync(Ferry.Core.Browse.PageLink album, CancellationToken cancel) => throw new NotSupportedException(); + } + + private AgentHost StartAgent(CheckedSite site) + { + var host = AgentHost.Start(Agent, browsing: new AgentBrowsing("Example", _ => true, (_, _) => null, _ => site), passed: pass => site.Pass = pass); + _running.Add(host); + return host; + } + + [Fact] + public async Task ASitesCheckIsShownToThePersonAndThePassGoesToTheAgentBeforeItIsAskedAgain() + { + var site = new CheckedSite(); + var pc = Pc(); + await AttachAsync(pc, StartAgent(site)); + var shown = new List(); + var passed = new PassedCheck(CheckedSite.Check.Page, "pass", "earned", "A browser"); + var browser = new RemotePageBrowser(pc, (check, _) => + { + shown.Add(check); + return Task.FromResult(passed); + }); + + var albums = await browser.AlbumsAsync(Ferry.Core.Browse.AlbumQuery.Latest, _stop.Token).WaitAsync(Patience); + + Assert.Empty(albums); + Assert.Equal([CheckedSite.Check], shown); + Assert.Equal(passed, site.Pass); + Assert.Equal(2, site.Asked); + } + + [Fact] + public async Task ACheckNobodyPassesIsTheFailureItWas() + { + var site = new CheckedSite(); + var pc = Pc(); + await AttachAsync(pc, StartAgent(site)); + + // nobody to show it to, and a person who closes the window + var unasked = await Assert.ThrowsAsync(() => new RemotePageBrowser(pc).AlbumsAsync(Ferry.Core.Browse.AlbumQuery.Latest, _stop.Token)); + var declined = await Assert.ThrowsAsync(() => + new RemotePageBrowser(pc, (_, _) => Task.FromResult(null)).AlbumsAsync(Ferry.Core.Browse.AlbumQuery.Latest, _stop.Token)); + + Assert.Equal(CheckedSite.Check, unasked.Check); + Assert.Equal("albums.example wants a person.", declined.Message); + Assert.Null(site.Pass); + } + + [Fact] + public async Task APassThatDidNotStickIsNotAskedForTwiceInOneRead() + { + var site = new CheckedSite(); + var pc = Pc(); + var host = AgentHost.Start(Agent, browsing: new AgentBrowsing("Example", _ => true, (_, _) => null, _ => site), passed: _ => { }); + _running.Add(host); + await AttachAsync(pc, host); + var shown = 0; + var browser = new RemotePageBrowser(pc, (check, _) => + { + shown++; + return Task.FromResult(new PassedCheck(check.Page, check.Cookie, "refused", "A browser")); + }); + + var failure = await Assert.ThrowsAsync(() => browser.AlbumsAsync(Ferry.Core.Browse.AlbumQuery.Latest, _stop.Token)); + + Assert.Equal(1, shown); + Assert.Equal(CheckedSite.Check, failure.Check); + } }