diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs index e2d3b90..36408ff 100644 --- a/src/SharpClient.Core/Connection/ITelnetConnection.cs +++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs @@ -21,6 +21,15 @@ public interface ITelnetConnection : IAsyncDisposable public ConnectionState State { get; } + /// Wrap the connection in TLS. Read on every connect and reconnect. + public bool UseTls { get; set; } + + /// + /// Accept a TLS certificate that fails validation (self-signed, expired, wrong host). Many MUSHes + /// run TLS with a self-signed certificate, so this is a per-world opt-in, not the default. + /// + public bool AllowInvalidCertificate { get; set; } + public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default); /// diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index 77f8560..25e300a 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -1,5 +1,7 @@ +using System.Net.Security; using System.Net.Sockets; using System.Text; +using SharpClient.Core.Rendering; using TelnetNegotiationCore.Builders; using TelnetNegotiationCore.Interpreters; using TelnetNegotiationCore.Models; @@ -13,6 +15,8 @@ public sealed class TelnetConnection : ITelnetConnection private readonly ReconnectOptions _reconnect; private TcpClient? _client; + // The client's stream, or the SslStream over it on a TLS world. Disposed with the client. + private Stream? _stream; private TelnetInterpreter? _interpreter; private Task? _readTask; private Task? _monitorTask; @@ -34,6 +38,8 @@ public sealed class TelnetConnection : ITelnetConnection // long-running (e.g. in-car) connection, without meaningful battery cost. private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45); + private static readonly TimeSpan TlsHandshakeTimeout = TimeSpan.FromSeconds(20); + // TNC names nobody unless told to: without this, TTYPE answers UNKNOWN and NEW-ENVIRON sends no // CLIENT_NAME. MTTS claims what AnsiParser renders: 16, 256 and 24-bit colour over UTF-8. private static ClientIdentity Identity { get; } = new("SharpClient") @@ -57,6 +63,12 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec public ConnectionState State { get; private set; } = ConnectionState.Disconnected; + /// + public bool UseTls { get; set; } + + /// + public bool AllowInvalidCertificate { get; set; } + /// /// Telnet CHARSET (RFC 2066) preference order offered during negotiation, most /// preferred first: UTF-8, then Latin-1 (ISO-8859-1), then US-ASCII. The negotiated @@ -82,8 +94,7 @@ public async Task ConnectAsync(string host, int port, CancellationToken cancella } catch { - _client?.Dispose(); - _client = null; + ReleaseTransport(); _interpreter = null; SetState(ConnectionState.Error); throw; @@ -173,8 +184,7 @@ public async Task DisconnectAsync() _interpreter = null; } - _client?.Dispose(); - _client = null; + ReleaseTransport(); if (_readTask is not null) { @@ -301,15 +311,59 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT // TCP would otherwise retransmit for ~15 min before failing, so the client sits "connected" // with no responses. This makes that surface in ~20s instead. TrySetTcpUserTimeout(client.Client); - await client.ConnectAsync(host, port, cancellationToken); + Stream? stream = null; + try + { + await client.ConnectAsync(host, port, cancellationToken); + stream = client.GetStream(); + if (UseTls) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, ValidateCertificate); + stream = ssl; + + // A peer that accepts the TCP connection but never answers the ClientHello (a plain + // telnet port, say) would otherwise hold the connect, or a reconnect attempt, forever. + using var handshake = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + handshake.CancelAfter(TlsHandshakeTimeout); + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { TargetHost = host }, handshake.Token); + } + + await StartInterpreterAsync(client, stream, host, port, cancellationToken); + } + catch + { + if (stream is not null) + { + await stream.DisposeAsync(); + } + + client.Dispose(); + throw; + } + } + + // Closes the TLS session (when there is one) and the socket under it. + private void ReleaseTransport() + { + _stream?.Dispose(); + _stream = null; + _client?.Dispose(); + _client = null; + } + private async Task StartInterpreterAsync(TcpClient client, Stream stream, string host, int port, CancellationToken cancellationToken) + { // The protocols AddDefaultMUDProtocols registers (NAWS, GMCP, MSDP, MSSP, TTYPE, CHARSET, EOR, // SGA, MXP), spelled out so NAWS can be WindowSizeProtocol: the helper always adds the base - // NAWSProtocol, and a second plugin for the same option is rejected. Plus Pueblo. + // NAWSProtocol, and a second plugin for the same option is rejected. Plus Pueblo, MCCP and + // NEW-ENVIRON. // Kept as locals because the server's banner (and so the Pueblo hello) can arrive before // BuildAndStartAsync returns and _interpreter is set. var mxp = new MXPProtocol(); - mxp.OnMXPEnabled(OnMxpEnabledAsync); + mxp.OnMXPEnabled(OnMxpEnabledAsync) + .OnMxpSupportRequested(asked => OnMxpSupportRequestedAsync(mxp, asked)) + .OnMxpVersionRequested(() => OnMxpVersionRequestedAsync(mxp)); var pueblo = new PuebloProtocol(); pueblo.OnPuebloOffered(() => OnPuebloOfferedAsync(pueblo, mxp)).OnPuebloEnabled(OnPuebloEnabledAsync); @@ -326,9 +380,14 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT .AddPlugin() .AddPlugin(mxp) .AddPlugin(pueblo) - .BuildAndStartAsync(client, cancellationToken); + // Both only answer the server: MCCP2 starts on the server's WILL, NEW-ENVIRON on its DO, + // and NEW-ENVIRON sends only what WithClientIdentity says (CLIENT_NAME, TERMINAL_TYPE, MTTS). + .AddPlugin().OnCompressionEnabled(OnCompressionAsync) + .AddPlugin() + .BuildAndStartAsync(stream, cancellationToken); _client = client; + _stream = stream; _interpreter = interpreter; _readTask = readTask; _host = host; @@ -373,8 +432,7 @@ private async Task ReconnectLoopAsync(CancellationToken token) // Tear down the dead client before the next attempt; keep the existing // state (Reconnecting) so we don't flicker through Disconnected. - _client?.Dispose(); - _client = null; + ReleaseTransport(); _interpreter = null; try @@ -445,6 +503,44 @@ private static async ValueTask OnPuebloOfferedAsync(PuebloProtocol pueblo, MXPPr } } + private bool ValidateCertificate( + object sender, + System.Security.Cryptography.X509Certificates.X509Certificate? certificate, + System.Security.Cryptography.X509Certificates.X509Chain? chain, + SslPolicyErrors errors) => + errors == SslPolicyErrors.None || AllowInvalidCertificate; + + private static async ValueTask OnMxpSupportRequestedAsync(MXPProtocol mxp, IReadOnlyList asked) + { + var (supported, unsupported) = MxpTags.Answer(asked); + try + { + await mxp.SendSupportsAsync(supported, unsupported); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + // The connection went away mid-answer. + } + } + + private static async ValueTask OnMxpVersionRequestedAsync(MXPProtocol mxp) + { + try + { + await mxp.SendVersionAsync(new MxpVersion(MxpTags.MxpRevision, null, Identity.Name, null, null)); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + // The connection went away mid-answer. + } + } + + private ValueTask OnCompressionAsync(int version, bool enabled) + { + NegotiationReceived?.Invoke(new NegotiationEvent($"MCCP{version}", enabled ? "compressing" : "stopped")); + return ValueTask.CompletedTask; + } + private ValueTask OnPuebloEnabledAsync(PuebloClient client) { PuebloEnabled?.Invoke(); diff --git a/src/SharpClient.Core/Domain/World.cs b/src/SharpClient.Core/Domain/World.cs index 4678a46..7444174 100644 --- a/src/SharpClient.Core/Domain/World.cs +++ b/src/SharpClient.Core/Domain/World.cs @@ -6,6 +6,12 @@ public sealed class World public string Name { get; set; } = string.Empty; public string Host { get; set; } = string.Empty; public int Port { get; set; } + + /// Connect over TLS. + public bool UseTls { get; set; } + + /// With , accept a self-signed or otherwise invalid certificate. + public bool AllowInvalidCertificate { get; set; } public List Characters { get; set; } = []; public List Triggers { get; set; } = []; // world-scope public List Aliases { get; set; } = []; // world-scope diff --git a/src/SharpClient.Core/Presentation/SettingsViewModel.cs b/src/SharpClient.Core/Presentation/SettingsViewModel.cs index 1d0c4d7..e994c88 100644 --- a/src/SharpClient.Core/Presentation/SettingsViewModel.cs +++ b/src/SharpClient.Core/Presentation/SettingsViewModel.cs @@ -48,6 +48,7 @@ public sealed class SettingsViewModel private string _accent; private bool _glow; private bool _scanlines; + private bool _showImages; public event Action? Changed; @@ -60,6 +61,7 @@ public SettingsViewModel(IPreferences prefs) _accent = prefs.GetString("Accent", "#9b7ed4"); _glow = prefs.GetBool ("Glow", true); _scanlines = prefs.GetBool ("Scanlines", true); + _showImages = prefs.GetBool ("ShowImages", true); // Clamp unknown accent to default so --acc2/soft/line don't desync. if (!s_accentMeta.ContainsKey(_accent)) @@ -105,6 +107,16 @@ public bool Scanlines set { _scanlines = value; _prefs.SetBool("Scanlines", value); Changed?.Invoke(); } } + /// + /// Load MXP/Pueblo inline images. Loading one tells the image host this device's address, so it + /// can be turned off; the image's name is shown in its place. + /// + public bool ShowImages + { + get => _showImages; + set { _showImages = value; _prefs.SetBool("ShowImages", value); Changed?.Invoke(); } + } + public string FontFamily => s_fontFamilies.TryGetValue(_outputFont, out var f) ? f : "'JetBrains Mono',ui-monospace,monospace"; diff --git a/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs b/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs index aca7f15..fa890cd 100644 --- a/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs +++ b/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs @@ -44,9 +44,25 @@ public async Task LoadAsync(CancellationToken cancellationToken = default) RaiseChanged(); } - public async Task AddWorldAsync(string name, string host, int port, CancellationToken cancellationToken = default) + public Task AddWorldAsync(string name, string host, int port, CancellationToken cancellationToken = default) => + AddWorldAsync(name, host, port, useTls: false, allowInvalidCertificate: false, cancellationToken); + + public async Task AddWorldAsync( + string name, + string host, + int port, + bool useTls, + bool allowInvalidCertificate, + CancellationToken cancellationToken = default) { - var world = new World { Name = name, Host = host, Port = port }; + var world = new World + { + Name = name, + Host = host, + Port = port, + UseTls = useTls, + AllowInvalidCertificate = allowInvalidCertificate, + }; await _store.AddWorldAsync(world, cancellationToken); await LoadAsync(cancellationToken); } diff --git a/src/SharpClient.Core/Rendering/MxpTags.cs b/src/SharpClient.Core/Rendering/MxpTags.cs new file mode 100644 index 0000000..305cb6e --- /dev/null +++ b/src/SharpClient.Core/Rendering/MxpTags.cs @@ -0,0 +1,80 @@ +namespace SharpClient.Core.Rendering; + +/// +/// What SharpClient's MXP rendering () acts on, for answering a server's +/// <SUPPORT>. A tag or attribute is listed only when the parser does something with it: +/// claiming more makes a server send markup that is then shown stripped. +/// +public static class MxpTags +{ + /// The MXP revision answered to <VERSION>. + public const string MxpRevision = "1.0"; + + // Each rendered tag with the attributes the parser reads from it. + private static readonly (string Tag, string[] Attributes)[] s_rendered = + [ + ("send", ["href", "hint"]), + ("image", ["fname", "url", "t"]), + ]; + + public static IReadOnlyList Supported { get; } = [.. s_rendered.Select(r => r.Tag)]; + + private static readonly Dictionary s_attributes = + s_rendered.ToDictionary(r => r.Tag, r => r.Attributes, StringComparer.OrdinalIgnoreCase); + + /// + /// The answer to a <SUPPORT> naming : every supported tag when + /// it named nothing, otherwise each entry as supported or not. tag.attr is supported when the + /// parser reads that attribute; tag.* expands to each attribute of a supported tag, as the + /// specification asks, and is -tag.* for a tag this client does not render. + /// + public static (IReadOnlyList Supported, IReadOnlyList Unsupported) Answer(IReadOnlyList asked) + { + if (asked.Count == 0) + { + return (Supported, []); + } + + var supported = new List(); + var unsupported = new List(); + foreach (var entry in asked) + { + var name = entry.Trim('"', '\''); + if (name.Length == 0) + { + continue; + } + + var dot = name.IndexOf('.', StringComparison.Ordinal); + if (dot < 0) + { + (IsSupported(name) ? supported : unsupported).Add(name); + continue; + } + + var tag = name[..dot]; + var attribute = name[(dot + 1)..]; + var tagSupported = s_attributes.TryGetValue(tag, out var known); + var attributes = known ?? Array.Empty(); + if (attribute == "*") + { + if (tagSupported) + { + supported.AddRange(attributes.Select(a => $"{tag}.{a}")); + } + else + { + unsupported.Add(name); + } + } + else + { + (attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name); + } + } + + return (supported, unsupported); + } + + private static bool IsSupported(string tag) => s_attributes.ContainsKey(tag); +} diff --git a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs index cb4e9ca..303477b 100644 --- a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs +++ b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs @@ -46,6 +46,8 @@ public async Task LaunchAsync( CancellationToken cancellationToken = default) { var connection = _connFactory.CreateConnection(); + connection.UseTls = world.UseTls; + connection.AllowInvalidCertificate = world.UseTls && world.AllowInvalidCertificate; var aliasRules = MergeAliases(world.Aliases, character.Aliases); var triggerRules = MergeTriggers(world.Triggers, character.Triggers); diff --git a/src/SharpClient.Data/WorldStore.cs b/src/SharpClient.Data/WorldStore.cs index 8697dc3..4a5a24d 100644 --- a/src/SharpClient.Data/WorldStore.cs +++ b/src/SharpClient.Data/WorldStore.cs @@ -105,6 +105,75 @@ private async Task EnsureSchemaAsync(CancellationToken cancellationToken) { if (_schemaEnsured) return; await _db.Database.EnsureCreatedAsync(cancellationToken); + await AddMissingColumnsAsync(cancellationToken); _schemaEnsured = true; } + + // The schema comes from EnsureCreated, which does nothing to a database that already exists, so a + // column added to the model after a user's database was created has to be added here. Each entry + // is NOT NULL with a default, so existing rows read as they did before the column existed. + private static readonly (string Table, string Column, string Definition)[] s_addedColumns = + [ + ("Worlds", nameof(World.UseTls), "INTEGER NOT NULL DEFAULT 0"), + ("Worlds", nameof(World.AllowInvalidCertificate), "INTEGER NOT NULL DEFAULT 0"), + ]; + + private async Task AddMissingColumnsAsync(CancellationToken cancellationToken) + { + var connection = _db.Database.GetDbConnection(); + var opened = connection.State != System.Data.ConnectionState.Open; + if (opened) + { + await connection.OpenAsync(cancellationToken); + } + + try + { + foreach (var group in s_addedColumns.GroupBy(c => c.Table)) + { + var existing = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var query = connection.CreateCommand()) + { + // Table names are the constants above, never input. +#pragma warning disable CA2100 + query.CommandText = $"PRAGMA table_info(\"{group.Key}\")"; +#pragma warning restore CA2100 + await using var reader = await query.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + existing.Add(reader.GetString(1)); + } + } + + foreach (var (table, column, definition) in group) + { + if (existing.Contains(column)) + { + continue; + } + + await using var alter = connection.CreateCommand(); +#pragma warning disable CA2100 + alter.CommandText = $"ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition}"; +#pragma warning restore CA2100 + try + { + await alter.ExecuteNonQueryAsync(cancellationToken); + } + catch (System.Data.Common.DbException ex) + when (ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase)) + { + // Another store on the same database added it between the check and here. + } + } + } + } + finally + { + if (opened) + { + await connection.CloseAsync(); + } + } + } } diff --git a/src/SharpClient.UI/Components/OutputView.razor b/src/SharpClient.UI/Components/OutputView.razor index 90390be..fc8ecac 100644 --- a/src/SharpClient.UI/Components/OutputView.razor +++ b/src/SharpClient.UI/Components/OutputView.razor @@ -7,7 +7,7 @@
@foreach (var segment in line.Segments) { - @if (segment.ImageUrl is not null) + @if (segment.ImageUrl is not null && ShowImages) { @* no-referrer: the image host learns nothing about where it was shown. *@ @if (segment.Command is not null && OnSendCommand is not null) @@ -43,4 +43,8 @@ /// Invoked when a clickable MXP/Pueblo command-link is clicked. [Parameter] public Func? OnSendCommand { get; set; } + + /// When false, an image segment renders as its text (its name or alt text) instead. + [Parameter] + public bool ShowImages { get; set; } = true; } diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 9d76fe8..12882dd 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -60,7 +60,7 @@ } else { - + }
@if (_showProtocol && ProtocolVm is not null) diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor index 99e2c02..b8b58ff 100644 --- a/src/SharpClient.UI/Components/SettingsView.razor +++ b/src/SharpClient.UI/Components/SettingsView.razor @@ -87,6 +87,16 @@ +
+
Content
+ +
+ @if (LogReader.IsAvailable || LogExporter.IsAvailable) {
diff --git a/src/SharpClient.UI/Components/WorldEditor.razor b/src/SharpClient.UI/Components/WorldEditor.razor index e843aed..5338f8d 100644 --- a/src/SharpClient.UI/Components/WorldEditor.razor +++ b/src/SharpClient.UI/Components/WorldEditor.razor @@ -23,6 +23,17 @@ port + + @if (_useTls) + { + + }