Add WebSocket support for Chromium DevTools endpoints

Implemented WebSocket communication in ChromiumDevTools to support 'page/' endpoints, allowing bidirectional messaging with Chromium DevTools Protocol. Added timeout configuration, improved error handling, and refactored WebSocketManager for connection pooling and reconnection. Updated resources and configuration files to support new timeout settings. Also fixed editor.html to handle direct JSON responses for citi-query.
This commit is contained in:
Namhyeon Go 2025-07-31 16:59:03 +09:00
parent 2b30e864f0
commit 7b49817182
7 changed files with 207 additions and 45 deletions

View File

@ -114,6 +114,15 @@ namespace WelsonJS.Launcher.Properties {
}
}
/// <summary>
/// 5과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>
internal static string ChromiumDevToolsTimeout {
get {
return ResourceManager.GetString("ChromiumDevToolsTimeout", resourceCulture);
}
}
/// <summary>
/// msedge.exe과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>

View File

@ -205,4 +205,7 @@
<data name="ChromiumFileName" xml:space="preserve">
<value>msedge.exe</value>
</data>
<data name="ChromiumDevToolsTimeout" xml:space="preserve">
<value>5</value>
</data>
</root>

View File

@ -373,11 +373,6 @@ namespace WelsonJS.Launcher
{
data = xmlHeader + "\r\n" + data;
}
else if (mimeType == "application/json")
{
data = xmlHeader + "\r\n<json><![CDATA[" + data + "]]></json>";
mimeType = "application/xml";
}
ServeResource(context, Encoding.UTF8.GetBytes(data), mimeType, statusCode);
}

View File

@ -1,19 +1,24 @@
// DevTools.cs
// ChromiumDevTools.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace WelsonJS.Launcher.ResourceTools
{
public class ChromiumDevTools : IResourceTool
{
private ResourceServer Server;
private readonly ResourceServer Server;
private readonly HttpClient _httpClient;
private readonly WebSocketManager _wsManager = new WebSocketManager();
private const string Prefix = "devtools/";
public ChromiumDevTools(ResourceServer server, HttpClient httpClient)
@ -31,17 +36,99 @@ namespace WelsonJS.Launcher.ResourceTools
{
string endpoint = path.Substring(Prefix.Length);
try
if (endpoint.Equals("json", StringComparison.OrdinalIgnoreCase))
{
string url = Program.GetAppConfig("ChromiumDevToolsPrefix") + endpoint;
string data = await _httpClient.GetStringAsync(url);
try
{
string baseUrl = Program.GetAppConfig("ChromiumDevToolsPrefix"); // e.g., http://localhost:9222/
string url = baseUrl.TrimEnd('/') + "/" + endpoint;
string data = await _httpClient.GetStringAsync(url);
Server.ServeResource(context, data, "application/json");
}
catch (Exception ex)
{
Server.ServeResource(context, $"<error>Failed to process DevTools request. {EscapeXml(ex.Message)}</error>", "application/xml", 500);
}
return;
}
Server.ServeResource(context, data, "application/json");
}
catch (Exception ex)
if (endpoint.StartsWith("page/", StringComparison.OrdinalIgnoreCase))
{
Server.ServeResource(context, $"<error>Failed to process DevTools request. {ex.Message}</error>", "application/xml", 500);
// 기본 구성
string baseUrl = Program.GetAppConfig("ChromiumDevToolsPrefix");
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri uri))
{
Server.ServeResource(context, "<error>Invalid ChromiumDevToolsPrefix</error>", "application/xml", 500);
return;
}
string hostname = uri.Host;
int port = uri.Port;
// 포트 덮어쓰기: ?port=1234
string portQuery = context.Request.QueryString["port"];
if (!string.IsNullOrEmpty(portQuery))
{
int.TryParse(portQuery, out int parsedPort);
if (parsedPort > 0) port = parsedPort;
}
// 타임아웃 처리
int timeout = 5;
string timeoutConfig = Program.GetAppConfig("ChromiumDevToolsTimeout");
if (!string.IsNullOrEmpty(timeoutConfig))
int.TryParse(timeoutConfig, out timeout);
// 경로
string wsPath = "devtools/" + endpoint;
// 본문 읽기
string postBody;
using (var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding))
postBody = await reader.ReadToEndAsync();
ClientWebSocket ws;
try
{
ws = await _wsManager.GetOrCreateAsync(hostname, port, wsPath);
}
catch (Exception ex)
{
Server.ServeResource(context, $"<error>WebSocket connection failed: {EscapeXml(ex.Message)}</error>", "application/xml", 502);
return;
}
try
{
var sendBuffer = Encoding.UTF8.GetBytes(postBody);
var sendToken = timeout == 0 ? CancellationToken.None : new CancellationTokenSource(TimeSpan.FromSeconds(timeout)).Token;
await ws.SendAsync(new ArraySegment<byte>(sendBuffer), WebSocketMessageType.Text, true, sendToken);
var recvBuffer = new byte[4096];
var recvToken = timeout == 0 ? CancellationToken.None : new CancellationTokenSource(TimeSpan.FromSeconds(timeout)).Token;
var result = await ws.ReceiveAsync(new ArraySegment<byte>(recvBuffer), recvToken);
string response = Encoding.UTF8.GetString(recvBuffer, 0, result.Count);
Server.ServeResource(context, response, "application/json", 200);
}
catch (OperationCanceledException)
{
Server.ServeResource(context, "<error>Timeout occurred</error>", "application/xml", 504);
}
catch (Exception ex)
{
_wsManager.Remove(hostname, port, wsPath);
Server.ServeResource(context, $"<error>WebSocket communication error: {EscapeXml(ex.Message)}</error>", "application/xml", 500);
}
return;
}
Server.ServeResource(context, "<error>Invalid DevTools endpoint</error>", "application/xml", 404);
}
private string EscapeXml(string text)
{
return WebUtility.HtmlEncode(text);
}
}
}

View File

@ -1,6 +1,13 @@
using System;
// WebSocketManager.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -8,46 +15,109 @@ namespace WelsonJS.Launcher
{
public class WebSocketManager
{
private readonly ConcurrentDictionary<int, ClientWebSocket> _wsPool;
public WebSocketManager() {
_wsPool = new ConcurrentDictionary<int, ClientWebSocket>();
private class WebSocketEntry
{
public ClientWebSocket Socket { get; set; }
public string Host { get; set; }
public int Port { get; set; }
public string Path { get; set; }
}
public async Task<ClientWebSocket> GetOrCreateAsync(int port)
{
if (_wsPool.TryGetValue(port, out var ws) && ws.State == WebSocketState.Open)
return ws;
private readonly ConcurrentDictionary<string, WebSocketEntry> _wsPool = new ConcurrentDictionary<string, WebSocketEntry>();
if (ws != null)
private string MakeKey(string host, int port, string path)
{
return host + ":" + port + "/" + path;
}
public async Task<ClientWebSocket> GetOrCreateAsync(string host, int port, string path)
{
string key = MakeKey(host, port, path);
if (_wsPool.TryGetValue(key, out var entry) && entry.Socket?.State == WebSocketState.Open)
return entry.Socket;
// 재연결 필요
if (entry != null)
{
_wsPool.TryRemove(port, out _);
ws.Dispose();
_wsPool.TryRemove(key, out _);
entry.Socket?.Dispose();
}
var newWs = new ClientWebSocket();
var uri = new Uri($"ws://localhost:{port}/ws");
var ws = new ClientWebSocket();
Uri uri = new Uri($"ws://{host}:{port}/{path}");
try
{
await newWs.ConnectAsync(uri, CancellationToken.None);
_wsPool[port] = newWs;
return newWs;
await ws.ConnectAsync(uri, CancellationToken.None);
_wsPool[key] = new WebSocketEntry
{
Socket = ws,
Host = host,
Port = port,
Path = path
};
return ws;
}
catch
{
newWs.Dispose();
ws.Dispose();
throw;
}
}
public void Remove(int port)
public void Remove(string host, int port, string path)
{
if (_wsPool.TryRemove(port, out var ws))
string key = MakeKey(host, port, path);
if (_wsPool.TryRemove(key, out var entry))
{
ws.Abort();
ws.Dispose();
entry.Socket?.Abort();
entry.Socket?.Dispose();
}
}
public async Task<bool> SendWithReconnectAsync(string host, int port, string path, byte[] message, CancellationToken token)
{
ClientWebSocket ws;
try
{
ws = await GetOrCreateAsync(host, port, path);
await ws.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Text, true, token);
return true;
}
catch
{
Remove(host, port, path);
try
{
ws = await GetOrCreateAsync(host, port, path);
await ws.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Text, true, token);
return true;
}
catch
{
Remove(host, port, path);
return false;
}
}
}
public async Task<string> SendAndReceiveAsync(string host, int port, string path, string message, int timeoutSeconds)
{
var buffer = Encoding.UTF8.GetBytes(message);
CancellationTokenSource cts = timeoutSeconds > 0
? new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))
: new CancellationTokenSource();
if (!await SendWithReconnectAsync(host, port, path, buffer, cts.Token))
throw new IOException("Failed to send after reconnect");
ClientWebSocket ws = await GetOrCreateAsync(host, port, path);
byte[] recvBuffer = new byte[4096];
WebSocketReceiveResult result = await ws.ReceiveAsync(new ArraySegment<byte>(recvBuffer), cts.Token);
return Encoding.UTF8.GetString(recvBuffer, 0, result.Count);
}
}
}

View File

@ -6,6 +6,7 @@
<add key="RepositoryUrl" value="https://github.com/gnh1201/welsonjs"/>
<add key="CopilotUrl" value="https://copilot.microsoft.com/"/>
<add key="ChromiumDevToolsPrefix" value="http://localhost:9222/"/>
<add key="ChromiumDevToolsTimeout" value="5"/>
<add key="ChromiumFileName" value="msedge.exe"/>
<add key="AzureAiServicePrefix" value="https://ai-catswords656881030318.services.ai.azure.com/"/>
<add key="AzureAiServiceApiKey" value=""/>

View File

@ -590,10 +590,7 @@
const ip = encodeURIComponent(hostname.trim());
axios.get(`/citi-query/${hostname}`).then(response => {
const parser = new XMLParser();
const result = parser.parse(response.data);
const data = JSON.parse(result.json);
if (!data) {
if (!response) {
appendTextToEditor("\n// No data returned from Criminal IP.");
return;
}
@ -603,10 +600,10 @@
// network port data
lines.push(`## Network ports:`);
if (data.port.data.length == 0) {
if (response.port.data.length == 0) {
lines.push(`* No open ports found.`);
} else {
data.port.data.forEach(x => {
response.port.data.forEach(x => {
lines.push(`### ${x.open_port_no}/${x.socket}`);
lines.push(`* Application: ${x.app_name} ${x.app_version}`);
lines.push(`* Discovered hostnames: ${x.dns_names}`);
@ -616,10 +613,10 @@
// vulnerability data
lines.push(`## Vulnerabilities:`);
if (data.vulnerability.data.length == 0) {
if (response.vulnerability.data.length == 0) {
lines.push(`* No vulnerabilities found.`);
} else {
data.vulnerability.data.forEach(x => {
response.vulnerability.data.forEach(x => {
lines.push(`### ${x.cve_id}`);
lines.push(`* ${x.cve_description}`);
lines.push(`* CVSSV2 Score: ${x.cvssv2_score}`);