refactor: split node agent and Go control plane

This commit is contained in:
2026-09-11 16:57:31 +08:00
parent 078c22c73b
commit c86ba9c4d7
129 changed files with 654 additions and 77 deletions
@@ -0,0 +1,182 @@
using Microsoft.Data.Sqlite;
using System.Text;
using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record DatabaseMetadata(string CipherVersion, string SqliteVersion, long SchemaObjectCount, IReadOnlyList<string> SchemaObjects, bool WritesRejected);
public static class SqlCipherDatabaseReader
{
private static readonly Lazy<bool> Initialized = new(() =>
{
SQLitePCL.Batteries_V2.Init();
return true;
});
public static async Task<DatabaseMetadata> ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
{
Validate(databasePath, hexKey);
_ = Initialized.Value;
try
{
await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false);
var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken).ConfigureAwait(false);
var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken).ConfigureAwait(false);
var count = await ReadSchemaCountAsync(connection, cancellationToken).ConfigureAwait(false);
var names = new List<string>();
await using (var command = connection.CreateCommand())
{
command.CommandText = "SELECT name FROM sqlite_master WHERE name IS NOT NULL ORDER BY name LIMIT 100;";
command.CommandTimeout = 10;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var name = reader.GetString(0);
names.Add(name.Length <= 256 ? name : name[..256]);
}
}
// Inspect SQLite's connection state; never attempt writes against the user's database.
var writesRejected = SQLitePCL.raw.sqlite3_db_readonly(connection.Handle, "main") == 1;
if (!writesRejected)
{
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher connection did not report read-only mode.");
}
return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected);
}
catch (SqliteException exception)
{
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not open or read the selected database in read-only mode.", exception);
}
}
/// <summary>Runs a caller-provided read-only SQL query and returns rows keyed by column name. Values are limited to scalars and UTF-8 strings.</summary>
public static async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryRowsAsync(
string databasePath, string hexKey, string sql, IReadOnlyList<KeyValuePair<string, object?>>? parameters, CancellationToken cancellationToken)
{
Validate(databasePath, hexKey);
_ = Initialized.Value;
var rows = new List<IReadOnlyDictionary<string, object?>>();
try
{
await using var connection = await OpenReadOnlyAsync(databasePath, hexKey, cancellationToken).ConfigureAwait(false);
await using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = 10;
if (parameters is not null)
{
foreach (var parameter in parameters)
{
command.Parameters.AddWithValue(parameter.Key, parameter.Value ?? DBNull.Value);
}
}
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < reader.FieldCount; index++)
{
var value = reader.IsDBNull(index) ? null : reader.GetValue(index);
row[reader.GetName(index)] = Normalize(value);
}
rows.Add(row);
}
return rows;
}
catch (SqliteException exception)
{
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not run the read-only query.", exception);
}
}
private static void Validate(string databasePath, string hexKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters.");
}
}
private static async Task<SqliteConnection> OpenReadOnlyAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
{
var page = new byte[SqlCipherPageVerifier.PageSize];
await using (var file = new FileStream(databasePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
SqlCipherPageVerifier.PageSize, FileOptions.Asynchronous))
await file.ReadExactlyAsync(page, cancellationToken).ConfigureAwait(false);
if (!SqlCipherPageVerifier.VerifyHexKey(page, hexKey))
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The cached key failed current database page-1 HMAC verification; refresh the selected account key.");
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = Path.GetFullPath(databasePath),
Mode = SqliteOpenMode.ReadOnly,
Cache = SqliteCacheMode.Private,
Pooling = false,
DefaultTimeout = 10
}.ToString();
var connection = new SqliteConnection(connectionString);
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using (var command = connection.CreateCommand())
{
// nosemgrep:csharp-sqli - hexKey is validated to exactly 64 hex chars before this call; no injection is possible.
command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";";
command.CommandTimeout = 10;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;";
command.CommandTimeout = 10;
await command.ExecuteNonQueryAsync(cancellationToken);
}
return connection;
}
catch
{
await connection.DisposeAsync();
throw;
}
}
private static object? Normalize(object? value) => value switch
{
byte[] bytes => bytes.Length <= 256 ? Encoding.UTF8.GetString(bytes) : $"[bytes:{bytes.Length}]",
DBNull => null,
_ => value
};
private static async Task<string> ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "PRAGMA cipher_version;";
command.CommandTimeout = 10;
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
}
private static async Task<string> ReadSqliteVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT sqlite_version();";
command.CommandTimeout = 10;
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
}
private static async Task<long> ReadSchemaCountAsync(SqliteConnection connection, CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT count(*) FROM sqlite_master;";
command.CommandTimeout = 10;
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture);
}
}