Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Interception/Certificates/CertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ private static async Task<X509Certificate2> LoadOrCreateCaCert(string path)
return cert;
}

private static readonly string[] RequiredDnsNames = ["do.pishock.com", "ps.pishock.com"];

private static async Task<X509Certificate2> LoadOrCreateServerCert(string path, X509Certificate2 caCert)
{
if (File.Exists(path))
Expand All @@ -89,7 +91,7 @@ private static async Task<X509Certificate2> LoadOrCreateServerCert(string path,

var loaded = X509CertificateLoader.LoadPkcs12(buffer, CertPassword,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
if (loaded.NotAfter > DateTime.UtcNow.AddDays(30))
if (loaded.NotAfter > DateTime.UtcNow.AddDays(30) && HasRequiredSans(loaded))
return loaded;
loaded.Dispose();
}
Expand All @@ -107,6 +109,7 @@ private static async Task<X509Certificate2> LoadOrCreateServerCert(string path,

var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("do.pishock.com");
sanBuilder.AddDnsName("ps.pishock.com");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Regenerate existing server certs for new ps SAN

Adding ps.pishock.com to SAN here only affects newly generated certificates; LoadOrCreateServerCert still returns any existing, unexpired interception-server.pfx, so upgraded users keep a cert that only matches do.pishock.com and TLS validation for https://ps.pishock.com will fail until they manually delete/regenerate the cert.

Useful? React with 👍 / 👎.

sanBuilder.AddIpAddress(IPAddress.Loopback);
req.CertificateExtensions.Add(sanBuilder.Build());

Expand All @@ -127,6 +130,15 @@ private static async Task<X509Certificate2> LoadOrCreateServerCert(string path,
return exported;
}

private static bool HasRequiredSans(X509Certificate2 cert)
{
var sanExtension = cert.Extensions.OfType<X509SubjectAlternativeNameExtension>().FirstOrDefault();
if (sanExtension == null) return false;

var dnsNames = sanExtension.EnumerateDnsNames().ToHashSet(StringComparer.OrdinalIgnoreCase);
return RequiredDnsNames.All(dnsNames.Contains);
}

private bool CheckCaTrusted()
{
if (_caCert == null) return false;
Expand Down
41 changes: 30 additions & 11 deletions Interception/HostsFile/HostsFileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ namespace OpenShock.Desktop.Modules.Interception.HostsFile;
public sealed class HostsFileManager
{
private const string HostsPath = @"C:\Windows\System32\drivers\etc\hosts";
private const string HostEntry = "127.0.0.1 do.pishock.com";
private const string Marker = "# OpenShock Interception";

private static readonly string[] HostEntries =
[
$"127.0.0.1 do.pishock.com {Marker}",
$"127.0.0.1 ps.pishock.com {Marker}"
];

public bool IsEnabled { get; private set; }

public async Task EnableAsync()
{
if (IsEnabled) return;
var line = $"{HostEntry} {Marker}";
await RunElevatedHostsCommand($"add \"{line}\"");
if (IsEnabled && !NeedsUpdate) return;
await RunElevatedHostsCommand("add");
IsEnabled = true;
NeedsUpdate = false;
await FlushDns();
}

Expand All @@ -28,31 +33,45 @@ public async Task DisableAsync()
await FlushDns();
}

public bool NeedsUpdate { get; private set; }

public async Task DetectCurrentState()
{
try
{
var content = await File.ReadAllTextAsync(HostsPath);
IsEnabled = content.Contains(Marker);
var hasAny = content.Contains(Marker);
var hasAll = content.Contains("do.pishock.com") && content.Contains("ps.pishock.com");
IsEnabled = hasAny;
NeedsUpdate = hasAny && !hasAll;
}
catch
{
IsEnabled = false;
NeedsUpdate = false;
}
}

private static async Task RunElevatedHostsCommand(string action)
{
string script;
if (action.StartsWith("add"))
if (action == "add")
{
var line = action.Substring(4).Trim();
// Check each host entry individually so upgrades from the old
// single-entry format pick up the new ps.pishock.com line.
var linesArray = string.Join(",", HostEntries.Select(e => $"'{e}'"));
script = string.Join("\n",
$"$line = {line};",
$"$lines = @({linesArray});",
$"$hostsPath = '{HostsPath}';",
"$content = Get-Content $hostsPath -Raw -ErrorAction SilentlyContinue;",
"if ($content -notmatch 'OpenShock Interception') {",
" Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false",
"if (-not $content) { $content = '' }",
"$added = $false;",
"foreach ($line in $lines) {",
" $host = ($line -split '\\s+')[1];",
" if ($content -notmatch [regex]::Escape($host)) {",
" Add-Content -Path $hostsPath -Value \"`n$line\" -NoNewline:$false;",
" $added = $true",
" }",
"}");
}
else
Expand Down Expand Up @@ -104,4 +123,4 @@ private static async Task FlushDns()
// Best-effort DNS flush
}
}
}
}
6 changes: 4 additions & 2 deletions Interception/InterceptionConfig.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using OpenShock.Desktop.Modules.Interception.Server;

namespace OpenShock.Desktop.Modules.Interception;

public sealed class InterceptionConfig
{
public ushort Port { get; set; } = 443;
public bool AutoStart { get; set; } = true;
public Dictionary<string, Guid> ShareCodeMappings { get; set; } = new();
}
public Dictionary<string, ShareCodeMapping> ShareCodeMappings { get; set; } = new();
}
6 changes: 5 additions & 1 deletion Interception/InterceptionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,16 @@ public async Task StartAsync()
var cert = certManager.ServerCertificate;

var operateController = ActivatorUtilities.CreateInstance<DoWebApiController>(serviceProvider);
var psController = ActivatorUtilities.CreateInstance<PsWebApiController>(serviceProvider);
var healthController = new HealthWebApiController();

_server = new WebServer(o => o
.WithUrlPrefix($"https://*:{port}/")
.WithMode(HttpListenerMode.EmbedIO)
.WithCertificate(cert))
.WithWebApi("/api", m => m.WithController(() => operateController))
.WithWebApi("/PiShock", m => m.WithController(() => psController))
.WithWebApi("/Health", m => m.WithController(() => healthController))
;

_ = _server.RunAsync();
Expand All @@ -60,4 +64,4 @@ public async Task UpdateConfig(Action<InterceptionConfig> update)
update(Config);
await moduleConfig.Save();
}
}
}
44 changes: 25 additions & 19 deletions Interception/Server/DoWebApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public async Task Operate()
return;
}

if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId))
if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping))
{
_logger.LogError("Share code not mapped to any shocker: {Code}", request.Code);
HttpContext.Response.StatusCode = 404;
Expand All @@ -65,6 +65,15 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/
return;
}

if (mapping.ShockerIds.Count == 0)
{
_logger.LogError("Share code has no shockers configured: {Code}", request.Code);
HttpContext.Response.StatusCode = 404;
await HttpContext.SendStringAsync("Share code has no shockers configured", "text/plain",
Encoding.UTF8);
return;
}

var controlType = request.Op switch
{
0 => ControlType.Shock,
Expand All @@ -73,30 +82,27 @@ await HttpContext.SendStringAsync("Share code not mapped to any shocker", "text/
_ => ControlType.Vibrate
};

var durationMs = (ushort)Math.Clamp(request.Duration * 1000, 300, 30000);
var intensity = (byte)Math.Clamp(request.Intensity, 1, 100);
var durationMs = (ushort)Math.Clamp(request.Duration * 1000, mapping.EffectiveMinDuration * 1000, mapping.EffectiveMaxDuration * 1000);
var intensity = (byte)Math.Clamp(request.Intensity, mapping.EffectiveMinIntensity, mapping.EffectiveMaxIntensity);

if (request.Intensity <= 0) controlType = ControlType.Stop;

var controls = new[]
var controls = mapping.ShockerIds.Select(id => new ShockerControl
{
new ShockerControl
{
Id = shockerId,
Type = controlType,
Intensity = intensity,
Duration = durationMs
}
};
Id = id,
Type = controlType,
Intensity = intensity,
Duration = durationMs
}).ToArray();

var customName = request.Name ?? request.Username ?? "PiShock Interception";

try
{
await _openShockControl.Control(controls, customName);
_logger.LogInformation(
"PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on shocker {ShockerId} by {Name}",
controlType, intensity, durationMs / 1000.0, shockerId, customName);
"PiShock Do API: control command: {ControlType} {Intensity}% for {Duration}s on {ShockerCount} shocker(s) by {Name}",
controlType, intensity, durationMs / 1000.0, controls.Length, customName);
await HttpContext.SendStringAsync(
JsonSerializer.Serialize(new { success = true, message = "Operation Succeeded." }),
"application/json", Encoding.UTF8);
Expand Down Expand Up @@ -137,7 +143,7 @@ public async Task GetShockerInfo()
return;
}

if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var shockerId))
if (!_service.Config.ShareCodeMappings.TryGetValue(request.Code, out var mapping))
{
_logger.LogError("Share code not mapped to any shocker: {Code}", request.Code);
HttpContext.Response.StatusCode = 404;
Expand All @@ -147,15 +153,15 @@ public async Task GetShockerInfo()

var info = new
{
clientId = shockerId,
clientId = mapping.ShockerIds.FirstOrDefault(),
name = $"Shocker ({request.Code})",
maxIntensity = 100,
maxDuration = 15,
maxIntensity = (int)mapping.EffectiveMaxIntensity,
maxDuration = (int)mapping.EffectiveMaxDuration,
online = true
};

await HttpContext.SendStringAsync(
JsonSerializer.Serialize(info),
"application/json", Encoding.UTF8);
}
}
}
23 changes: 23 additions & 0 deletions Interception/Server/HealthWebApiController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text;
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;

namespace OpenShock.Desktop.Modules.Interception.Server;

public sealed class HealthWebApiController : WebApiController
{
[Route(HttpVerbs.Get, "/Check")]
public async Task Check()
{
HttpContext.Response.StatusCode = 200;
await HttpContext.SendStringAsync("OK", "text/plain", Encoding.UTF8);
}

[Route(HttpVerbs.Get, "/Server")]
public Task Server()
{
HttpContext.Response.StatusCode = 204;
return Task.CompletedTask;
}
}
Loading
Loading