From d49a523f33a2db09229759098fd729f56ed43272 Mon Sep 17 00:00:00 2001 From: iStorry <7252344+iStorry@users.noreply.github.com> Date: Sun, 15 Nov 2026 18:53:06 +0900 Subject: [PATCH] A site's check for a person is shown to the person, through the server A plugin's site may put a check before its pages that only a person in a browser can pass. A plugin says so with StageException.Check (a PersonCheck: the page and the cookie that says it was passed), and never answers it. On the Browser page the PC opens that page as it is in a WebView2 window with a profile of its own, reached through a SOCKS forward over ssh (ServerTunnel), so the site sees the server's address. When the cookie is there the window closes, the agent hears of it (AgentCommand.Passed), plugins that are an ICheckedPlugin are told, and the read is asked once more. The tunnel is the one run of ssh that forwards anything, on loopback, for as long as the window is open. The plugin contract's number stays: nothing built against it breaks. The window and the tunnel have not been run against a server yet. --- docs/writing-plugins.md | 17 ++ src/Ferry.Agent/AgentCommands.cs | 4 +- src/Ferry.Agent/ServerPlugins.cs | 9 +- src/Ferry.App/Services/AppServices.cs | 4 + src/Ferry.App/Services/BrowseHost.cs | 2 +- src/Ferry.App/Services/PersonChecks.cs | 189 +++++++++++++++++++ src/Ferry.Core/PersonCheck.cs | 20 ++ src/Ferry.Core/Plugins/IFerryPlugin.cs | 14 ++ src/Ferry.Core/StageException.cs | 6 + src/Ferry.Plugins.Host/InstalledPlugins.cs | 20 +- src/Ferry.Plugins.Server/AgentHost.cs | 22 ++- src/Ferry.Plugins.Server/AgentProtocol.cs | 13 +- src/Ferry.Plugins.Server/RemoteBrowse.cs | 20 +- src/Ferry.Plugins.Server/RemoteQueue.cs | 2 +- src/Ferry.Plugins.Server/ServerConnection.cs | 42 +++++ src/Ferry.Plugins.Server/ServerTunnel.cs | 107 +++++++++++ src/Ferry.Plugins.Server/SshArguments.cs | 17 +- tests/Ferry.Core.Tests/OpenSshTests.cs | 15 ++ tests/Ferry.Core.Tests/RemoteQueueTests.cs | 90 +++++++++ 19 files changed, 593 insertions(+), 20 deletions(-) create mode 100644 src/Ferry.App/Services/PersonChecks.cs create mode 100644 src/Ferry.Core/PersonCheck.cs create mode 100644 src/Ferry.Plugins.Server/ServerTunnel.cs 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); + } }