SwyxTray und Firefox-Plugin in das Repository aufnehmen
Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang, Firefox-Erweiterung und Browser-Beispielclient.
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SwyxTray.Web;
|
||||
|
||||
/// <summary>
|
||||
/// Der HTTP-Teil des WebSocket-Aufbaus (RFC 6455, Abschnitt 4.2). Das ist der
|
||||
/// einzige Teil des Protokolls, der von Hand entsteht — Rahmen, Maskierung,
|
||||
/// Ping/Pong und der Schliessvorgang kommen danach von
|
||||
/// <see cref="System.Net.WebSockets.WebSocket.CreateFromStream"/>.
|
||||
/// </summary>
|
||||
internal static class WebSocketHandshake
|
||||
{
|
||||
/// <summary>Feste GUID aus RFC 6455 zur Bildung des Accept-Werts.</summary>
|
||||
private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
private const int MaxHeaderBytes = 16 * 1024;
|
||||
|
||||
public const string SubProtocol = "swyxtray.v1";
|
||||
|
||||
/// <summary>Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding.</summary>
|
||||
private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"];
|
||||
|
||||
internal sealed record Request(
|
||||
string Method,
|
||||
string Target,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string? Token,
|
||||
string Origin,
|
||||
string? SubProtocol);
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Handshake-Anfrage. Bewusst byteweise bis zur Leerzeile: alles
|
||||
/// dahinter gehoert bereits dem WebSocket, und ein zu grosszuegiges Lesen
|
||||
/// wuerde die ersten Frames verschlucken.
|
||||
/// </summary>
|
||||
public static async Task<Request?> ReadAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[1];
|
||||
var raw = new List<byte>(1024);
|
||||
|
||||
while (raw.Count < MaxHeaderBytes)
|
||||
{
|
||||
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
raw.Add(buffer[0]);
|
||||
|
||||
if (raw.Count >= 4 &&
|
||||
raw[^4] == (byte)'\r' && raw[^3] == (byte)'\n' &&
|
||||
raw[^2] == (byte)'\r' && raw[^1] == (byte)'\n')
|
||||
{
|
||||
return Parse(Encoding.ASCII.GetString(raw.ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Request? Parse(string text)
|
||||
{
|
||||
var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var requestLine = lines[0].Split(' ');
|
||||
if (requestLine.Length < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var separator = line.IndexOf(':');
|
||||
if (separator <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = line[..separator].Trim();
|
||||
var value = line[(separator + 1)..].Trim();
|
||||
|
||||
// Mehrfach gesendete Header zusammenfassen, wie in HTTP vorgesehen.
|
||||
headers[name] = headers.TryGetValue(name, out var existing)
|
||||
? $"{existing}, {value}"
|
||||
: value;
|
||||
}
|
||||
|
||||
var offered = headers.GetValueOrDefault("Sec-WebSocket-Protocol", string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return new Request(
|
||||
Method: requestLine[0],
|
||||
Target: requestLine[1],
|
||||
Headers: headers,
|
||||
Token: TokenFrom(requestLine[1], offered),
|
||||
Origin: headers.GetValueOrDefault("Origin", string.Empty),
|
||||
SubProtocol: offered.Contains(SubProtocol) ? SubProtocol : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Token darf in der Abfragezeichenfolge (<c>?token=…</c>) oder als
|
||||
/// Unterprotokoll <c>token.…</c> kommen. Die zweite Form ist fuer Seiten
|
||||
/// nuetzlich, die das Geheimnis nicht in einer URL stehen haben wollen —
|
||||
/// die WebSocket-API des Browsers kennt keine eigenen Header.
|
||||
/// </summary>
|
||||
private static string? TokenFrom(string target, IEnumerable<string> offeredProtocols)
|
||||
{
|
||||
var fromProtocol = offeredProtocols
|
||||
.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal))?["token.".Length..];
|
||||
|
||||
if (!string.IsNullOrEmpty(fromProtocol))
|
||||
{
|
||||
return fromProtocol;
|
||||
}
|
||||
|
||||
var query = target.IndexOf('?');
|
||||
if (query < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var pair in target[(query + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equals = pair.IndexOf('=');
|
||||
if (equals > 0 && pair[..equals].Equals("token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Uri.UnescapeDataString(pair[(equals + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prueft die Anfrage. Liefert <c>null</c>, wenn alles passt, sonst
|
||||
/// HTTP-Status und Begruendung fuer die Absage.
|
||||
/// </summary>
|
||||
public static (int Status, string Reason)? Validate(Request request, WebSocketConfig config)
|
||||
{
|
||||
if (!request.Method.Equals("GET", StringComparison.Ordinal))
|
||||
{
|
||||
return (405, "Nur GET.");
|
||||
}
|
||||
|
||||
if (!Contains(request.Headers.GetValueOrDefault("Upgrade"), "websocket") ||
|
||||
!Contains(request.Headers.GetValueOrDefault("Connection"), "upgrade"))
|
||||
{
|
||||
return (400, "Kein WebSocket-Upgrade.");
|
||||
}
|
||||
|
||||
if (request.Headers.GetValueOrDefault("Sec-WebSocket-Version") != "13")
|
||||
{
|
||||
return (426, "Es wird nur Sec-WebSocket-Version 13 unterstuetzt.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.Headers.GetValueOrDefault("Sec-WebSocket-Key")))
|
||||
{
|
||||
return (400, "Sec-WebSocket-Key fehlt.");
|
||||
}
|
||||
|
||||
// Bis hierher ging es nur um ein wohlgeformtes Upgrade; alles Weitere
|
||||
// sind die Zugriffsschranken — und die sind zur Zeit abgeschaltet,
|
||||
// siehe WebSocketConfig.SecurityDisabled.
|
||||
if (WebSocketConfig.SecurityDisabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ohne diese Pruefung koennte eine Seite einen eigenen Namen auf
|
||||
// 127.0.0.1 zeigen lassen und so aus ihrem Ursprung heraus verbinden.
|
||||
// Bei Zugriff aus dem Netz entfaellt sie: welchen Namen oder welche
|
||||
// Adresse ein entfernter Rechner verwendet, kann die App nicht wissen.
|
||||
if (!config.AllowRemoteAccess)
|
||||
{
|
||||
var raw = request.Headers.GetValueOrDefault("Host", string.Empty);
|
||||
var host = raw.StartsWith('[') ? "[::1]" : raw.Split(':')[0];
|
||||
|
||||
if (!LoopbackHosts.Contains(host, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return (400, "Unerwarteter Host — erlaubt ist nur 127.0.0.1 bzw. localhost.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.IsTokenValid(request.Token))
|
||||
{
|
||||
return (403, "Token fehlt oder ist falsch.");
|
||||
}
|
||||
|
||||
// Fehlt der Origin, ist der Aufrufer kein Browser. Das ist zulaessig,
|
||||
// weil das Token bereits nachgewiesen wurde; eine Webseite kann den
|
||||
// Header dagegen nicht weglassen, ihr Ursprung wird also stets geprueft.
|
||||
if (request.Origin.Length > 0 && !config.IsOriginAllowed(request.Origin))
|
||||
{
|
||||
return (403, $"Origin '{request.Origin}' ist nicht freigegeben.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Task AcceptAsync(Stream stream, Request request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.Headers["Sec-WebSocket-Key"];
|
||||
var accept = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + AcceptGuid)));
|
||||
|
||||
var response = new StringBuilder()
|
||||
.Append("HTTP/1.1 101 Switching Protocols\r\n")
|
||||
.Append("Upgrade: websocket\r\n")
|
||||
.Append("Connection: Upgrade\r\n")
|
||||
.Append($"Sec-WebSocket-Accept: {accept}\r\n");
|
||||
|
||||
if (request.SubProtocol is not null)
|
||||
{
|
||||
response.Append($"Sec-WebSocket-Protocol: {request.SubProtocol}\r\n");
|
||||
}
|
||||
|
||||
return WriteAsync(stream, response.Append("\r\n").ToString(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absage mit Klartext im Rumpf. Der Browser zeigt davon zwar nur den
|
||||
/// Status, aber im Protokoll der App und in einem Netzwerkmitschnitt steht
|
||||
/// damit der Grund — sonst bleibt nur ein wortloses Scheitern.
|
||||
/// </summary>
|
||||
public static Task RejectAsync(Stream stream, int status, string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes(reason);
|
||||
var head =
|
||||
$"HTTP/1.1 {status} {StatusText(status)}\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
$"Content-Length: {body.Length}\r\n" +
|
||||
"Connection: close\r\n\r\n";
|
||||
|
||||
return WriteAsync(stream, head + reason, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(Stream stream, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool Contains(string? headerValue, string token)
|
||||
=> headerValue is not null && headerValue.Contains(token, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string StatusText(int status) => status switch
|
||||
{
|
||||
400 => "Bad Request",
|
||||
403 => "Forbidden",
|
||||
405 => "Method Not Allowed",
|
||||
426 => "Upgrade Required",
|
||||
503 => "Service Unavailable",
|
||||
_ => "Error"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user