初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MatrixImageEditor;
|
||||
|
||||
public sealed class ImageConfig
|
||||
{
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } = 1;
|
||||
[JsonPropertyName("product")] public string Product { get; set; } = "";
|
||||
[JsonPropertyName("software_version")] public string SoftwareVersion { get; set; } = "";
|
||||
[JsonPropertyName("account")] public AccountConfig Account { get; set; } = new();
|
||||
[JsonPropertyName("wifi")] public WifiConfig Wifi { get; set; } = new();
|
||||
[JsonPropertyName("ipv4")] public Ipv4Config Ipv4 { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AccountConfig
|
||||
{
|
||||
[JsonPropertyName("username")] public string Username { get; set; } = "";
|
||||
[JsonPropertyName("password")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class WifiConfig
|
||||
{
|
||||
[JsonPropertyName("ssid")] public string Ssid { get; set; } = "";
|
||||
[JsonPropertyName("password")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class Ipv4Config
|
||||
{
|
||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "dhcp";
|
||||
[JsonPropertyName("address")] public string Address { get; set; } = "";
|
||||
[JsonPropertyName("prefix")] public int Prefix { get; set; }
|
||||
[JsonPropertyName("gateway")] public string Gateway { get; set; } = "";
|
||||
[JsonPropertyName("dns")] public List<string> Dns { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class ImageConfigCodec
|
||||
{
|
||||
public const int FileBytes = 64 * 1024;
|
||||
private const int HeaderBytes = 4096;
|
||||
private const int SlotBytes = (FileBytes - HeaderBytes) / 2;
|
||||
private const int SlotHeaderBytes = 52;
|
||||
private static readonly byte[] FileMagic = Encoding.ASCII.GetBytes("MSCCFG2\0");
|
||||
private static readonly byte[] SlotMagic = Encoding.ASCII.GetBytes("MSCSLOT\0");
|
||||
private static readonly Regex UsernamePattern = new("^[a-z_][a-z0-9_-]{0,31}$", RegexOptions.CultureInvariant);
|
||||
private static readonly Regex VersionPattern = new("^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", RegexOptions.CultureInvariant);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
};
|
||||
|
||||
public static ImageConfig Deserialize(string json)
|
||||
{
|
||||
var config = JsonSerializer.Deserialize<ImageConfig>(json, JsonOptions) ?? throw new InvalidDataException("配置 JSON 为空");
|
||||
Validate(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
public static string Serialize(ImageConfig config)
|
||||
{
|
||||
Validate(config);
|
||||
return JsonSerializer.Serialize(config, JsonOptions);
|
||||
}
|
||||
|
||||
public static ImageConfig Read(byte[] data) => ReadDetailed(data).Config;
|
||||
|
||||
public static (ImageConfig Config, ulong Generation, int Slot) ReadDetailed(byte[] data)
|
||||
{
|
||||
if (data.Length != FileBytes || !data.AsSpan(0, 8).SequenceEqual(FileMagic) || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(8, 4)) != 1 || BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(12, 4)) != FileBytes)
|
||||
{
|
||||
throw new InvalidDataException("镜像配置区头部无效");
|
||||
}
|
||||
var values = new List<(ImageConfig Config, ulong Generation, int Slot)>();
|
||||
for (var slot = 0; slot < 2; slot++)
|
||||
{
|
||||
var offset = HeaderBytes + slot * SlotBytes;
|
||||
var span = data.AsSpan(offset, SlotBytes);
|
||||
if (!span[..8].SequenceEqual(SlotMagic)) continue;
|
||||
var generation = BinaryPrimitives.ReadUInt64LittleEndian(span.Slice(8, 8));
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(16, 4));
|
||||
if (length <= 0 || length > SlotBytes - SlotHeaderBytes) continue;
|
||||
var payload = span.Slice(SlotHeaderBytes, length).ToArray();
|
||||
if (!SHA256.HashData(payload).AsSpan().SequenceEqual(span.Slice(20, 32))) continue;
|
||||
try
|
||||
{
|
||||
values.Add((Deserialize(Encoding.UTF8.GetString(payload)), generation, slot));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (values.Count == 0) throw new InvalidDataException("镜像配置区没有可恢复的有效副本");
|
||||
return values.OrderByDescending(value => value.Generation).ThenByDescending(value => value.Slot).First();
|
||||
}
|
||||
|
||||
public static (byte[] SlotData, int TargetSlot) EncodeUpdate(byte[] current, ImageConfig config)
|
||||
{
|
||||
Validate(config);
|
||||
var active = ReadDetailed(current);
|
||||
var target = 1 - active.Slot;
|
||||
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(config, JsonOptions) + "\n");
|
||||
if (payload.Length > SlotBytes - SlotHeaderBytes) throw new InvalidDataException("配置内容过大");
|
||||
var result = new byte[SlotBytes];
|
||||
SlotMagic.CopyTo(result, 0);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(result.AsSpan(8, 8), active.Generation + 1);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(16, 4), payload.Length);
|
||||
SHA256.HashData(payload).CopyTo(result, 20);
|
||||
payload.CopyTo(result, SlotHeaderBytes);
|
||||
return (result, target);
|
||||
}
|
||||
|
||||
public static int SlotOffset(int slot) => HeaderBytes + slot * SlotBytes;
|
||||
|
||||
public static void Validate(ImageConfig config)
|
||||
{
|
||||
if (config.Account is null || config.Wifi is null || config.Ipv4 is null) throw new InvalidDataException("镜像配置缺少必要部分");
|
||||
if (config.SchemaVersion != 1 || config.Product != "matrix-screen-controller-walnutpi") throw new InvalidDataException("镜像产品或配置版本不受支持");
|
||||
if (!VersionPattern.IsMatch(config.SoftwareVersion)) throw new InvalidDataException("软件版本格式无效");
|
||||
if (!UsernamePattern.IsMatch(config.Account.Username)) throw new InvalidDataException("用户名只能使用小写字母、数字、下划线和连字符,且最长 32 个字符");
|
||||
ValidateSecret(config.Account.Password, "账户密码", 8, 128);
|
||||
ValidateText(config.Wifi.Ssid, "Wi-Fi 名称", 1, 32);
|
||||
ValidateSecret(config.Wifi.Password, "Wi-Fi 密码", 8, 63);
|
||||
if (config.Ipv4.Mode == "dhcp")
|
||||
{
|
||||
config.Ipv4.Address = ""; config.Ipv4.Prefix = 0; config.Ipv4.Gateway = ""; config.Ipv4.Dns = new();
|
||||
}
|
||||
else if (config.Ipv4.Mode == "static")
|
||||
{
|
||||
if (!IPAddress.TryParse(config.Ipv4.Address, out var address) || address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 地址无效");
|
||||
if (!IPAddress.TryParse(config.Ipv4.Gateway, out var gateway) || gateway.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) throw new InvalidDataException("静态 IPv4 网关无效");
|
||||
if (config.Ipv4.Prefix is < 1 or > 32) throw new InvalidDataException("IPv4 前缀必须是 1 到 32");
|
||||
if (!SameSubnet(address, gateway, config.Ipv4.Prefix)) throw new InvalidDataException("静态 IPv4 网关必须与地址处于同一子网");
|
||||
if (config.Ipv4.Dns is null || config.Ipv4.Dns.Count is < 1 or > 4 || config.Ipv4.Dns.Any(item => !IPAddress.TryParse(item, out var parsed) || parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)) throw new InvalidDataException("DNS 必须包含 1 到 4 个 IPv4 地址");
|
||||
}
|
||||
else throw new InvalidDataException("IPv4 模式无效");
|
||||
}
|
||||
|
||||
private static void ValidateSecret(string value, string label, int minimum, int maximum)
|
||||
{
|
||||
if (value is null || value.Length < minimum || value.Length > maximum || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效");
|
||||
}
|
||||
|
||||
private static void ValidateText(string value, string label, int minimum, int maximumBytes)
|
||||
{
|
||||
if (value is null || value.Length < minimum || Encoding.UTF8.GetByteCount(value) > maximumBytes || value.IndexOfAny(['\0', '\r', '\n']) >= 0) throw new InvalidDataException($"{label}长度或字符无效");
|
||||
}
|
||||
|
||||
private static bool SameSubnet(IPAddress address, IPAddress gateway, int prefix)
|
||||
{
|
||||
var addressValue = BinaryPrimitives.ReadUInt32BigEndian(address.GetAddressBytes());
|
||||
var gatewayValue = BinaryPrimitives.ReadUInt32BigEndian(gateway.GetAddressBytes());
|
||||
var mask = prefix == 32 ? uint.MaxValue : uint.MaxValue << (32 - prefix);
|
||||
return (addressValue & mask) == (gatewayValue & mask);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user