Skip to content
9 changes: 9 additions & 0 deletions src/SharpClient.Core/Connection/ITelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ public interface ITelnetConnection : IAsyncDisposable

public ConnectionState State { get; }

/// <summary>Wrap the connection in TLS. Read on every connect and reconnect.</summary>
public bool UseTls { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool AllowInvalidCertificate { get; set; }

public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);

/// <summary>
Expand Down
116 changes: 106 additions & 10 deletions src/SharpClient.Core/Connection/TelnetConnection.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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")
Expand All @@ -57,6 +63,12 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec

public ConnectionState State { get; private set; } = ConnectionState.Disconnected;

/// <inheritdoc />
public bool UseTls { get; set; }

/// <inheritdoc />
public bool AllowInvalidCertificate { get; set; }

/// <summary>
/// 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
Expand All @@ -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;
Expand Down Expand Up @@ -173,8 +184,7 @@ public async Task DisconnectAsync()
_interpreter = null;
}

_client?.Dispose();
_client = null;
ReleaseTransport();

if (_readTask is not null)
{
Expand Down Expand Up @@ -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);

Expand All @@ -326,9 +380,14 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
.AddPlugin<SuppressGoAheadProtocol>()
.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<MCCPProtocol>().OnCompressionEnabled(OnCompressionAsync)
.AddPlugin<NewEnvironProtocol>()
.BuildAndStartAsync(stream, cancellationToken);

_client = client;
_stream = stream;
_interpreter = interpreter;
_readTask = readTask;
_host = host;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string> 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();
Expand Down
6 changes: 6 additions & 0 deletions src/SharpClient.Core/Domain/World.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>Connect over TLS.</summary>
public bool UseTls { get; set; }

/// <summary>With <see cref="UseTls"/>, accept a self-signed or otherwise invalid certificate.</summary>
public bool AllowInvalidCertificate { get; set; }
public List<Character> Characters { get; set; } = [];
public List<TriggerRule> Triggers { get; set; } = []; // world-scope
public List<AliasRule> Aliases { get; set; } = []; // world-scope
Expand Down
12 changes: 12 additions & 0 deletions src/SharpClient.Core/Presentation/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public sealed class SettingsViewModel
private string _accent;
private bool _glow;
private bool _scanlines;
private bool _showImages;

public event Action? Changed;

Expand All @@ -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))
Expand Down Expand Up @@ -105,6 +107,16 @@ public bool Scanlines
set { _scanlines = value; _prefs.SetBool("Scanlines", value); Changed?.Invoke(); }
}

/// <summary>
/// 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.
/// </summary>
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";
Expand Down
20 changes: 18 additions & 2 deletions src/SharpClient.Core/Presentation/WorldManagerViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
80 changes: 80 additions & 0 deletions src/SharpClient.Core/Rendering/MxpTags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace SharpClient.Core.Rendering;

/// <summary>
/// What SharpClient's MXP rendering (<see cref="AnsiParser"/>) acts on, for answering a server's
/// <c>&lt;SUPPORT&gt;</c>. 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.
/// </summary>
public static class MxpTags
{
/// <summary>The MXP revision answered to <c>&lt;VERSION&gt;</c>.</summary>
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<string> Supported { get; } = [.. s_rendered.Select(r => r.Tag)];

private static readonly Dictionary<string, string[]> s_attributes =
s_rendered.ToDictionary(r => r.Tag, r => r.Attributes, StringComparer.OrdinalIgnoreCase);

/// <summary>
/// The answer to a <c>&lt;SUPPORT&gt;</c> naming <paramref name="asked"/>: every supported tag when
/// it named nothing, otherwise each entry as supported or not. <c>tag.attr</c> is supported when the
/// parser reads that attribute; <c>tag.*</c> expands to each attribute of a supported tag, as the
/// specification asks, and is <c>-tag.*</c> for a tag this client does not render.
/// </summary>
public static (IReadOnlyList<string> Supported, IReadOnlyList<string> Unsupported) Answer(IReadOnlyList<string> asked)
{
if (asked.Count == 0)
{
return (Supported, []);
}

var supported = new List<string>();
var unsupported = new List<string>();
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<string>();
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);
}
2 changes: 2 additions & 0 deletions src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public async Task<ISession> 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);
Expand Down
Loading
Loading