Phase 4.7: MullvadVpnService + ThreemaService in den Core
- Beide Services nach Core (nutzen nur TerminalLogger/ServerSettings/JobManager, keine DB/TradingState/Modul-Typen). - Threema-ProjectReference von App -> Core umgehängt (App nutzte die Lib nur über ThreemaService). - Microsoft.Extensions.Hosting.Abstractions als Core-Paket (BackgroundService). - Toten Stub services/mullvad.cs gelöscht; Mullvad-DB-Usings entfernt. - Build 0 Fehler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c068b448e
commit
0ffc0413c8
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class MullvadVpnService : BackgroundService
|
||||
{
|
||||
private readonly TerminalLogger _logger;
|
||||
private ServerSettings _settings;
|
||||
private readonly string _settingsPath = "server_settings.xml";
|
||||
private bool _isConnected = false;
|
||||
private int _consecutiveFailures = 0;
|
||||
private readonly int _maxRetries = 3;
|
||||
|
||||
public bool IsConnected => _isConnected;
|
||||
|
||||
public MullvadVpnService(TerminalLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
}
|
||||
|
||||
public void ReloadSettings()
|
||||
{
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_settings.VpnEnabled)
|
||||
{
|
||||
await HealthCheckAsync();
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HealthCheckAsync()
|
||||
{
|
||||
if (!_settings.VpnEnabled) return true;
|
||||
|
||||
string status = await RunCliAsync("status");
|
||||
if (status.Contains("Connected"))
|
||||
{
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try reconnecting
|
||||
_logger.Warning("VPN is not connected. Attempting to reconnect...");
|
||||
if (await ConnectAsync()) return true;
|
||||
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= _maxRetries)
|
||||
{
|
||||
_logger.Error($"VPN unrecoverable after {_maxRetries} retries.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
if (!_settings.VpnEnabled) return true;
|
||||
|
||||
string status = await RunCliAsync("status");
|
||||
if (status.Contains("Connected"))
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.VpnLocation) || status.Contains(_settings.VpnLocation, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"VPN already connected tightly to {_settings.VpnLocation}");
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.MullvadAccount))
|
||||
{
|
||||
await RunCliAsync($"account login {_settings.MullvadAccount}");
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
await RunCliAsync("lan set allow");
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.VpnLocation))
|
||||
{
|
||||
await RunCliAsync($"relay set location {_settings.VpnLocation}");
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
await RunCliAsync("connect");
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
string check = await RunCliAsync("status");
|
||||
if (check.Contains("Connected"))
|
||||
{
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
_logger.Info($"VPN connected successfully: {check.Trim()}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Error("VPN failed to connect after waiting");
|
||||
_consecutiveFailures++;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DisconnectAsync()
|
||||
{
|
||||
string result = await RunCliAsync("disconnect");
|
||||
_isConnected = false;
|
||||
_logger.Info("VPN disconnected.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> RunCliAsync(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_settings.MullvadCliPath))
|
||||
{
|
||||
_logger.Error($"Mullvad CLI not found at: {_settings.MullvadCliPath}");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _settings.MullvadCliPath,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return string.Empty;
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
string output = await process.StandardOutput.ReadToEndAsync();
|
||||
string err = await process.StandardError.ReadToEndAsync();
|
||||
return string.IsNullOrWhiteSpace(output) ? err : output;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Mullvad CLI exc: {ex.Message}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
using IcgSoftware.Threema.CoreMsgApi;
|
||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class ThreemaService : BackgroundService
|
||||
{
|
||||
public event Action<string>? OnCommandReceived;
|
||||
|
||||
private readonly TerminalLogger _logger;
|
||||
private ServerSettings _settings;
|
||||
private readonly string _settingsPath = "server_settings.xml";
|
||||
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
private HttpListener? _httpListener;
|
||||
private APIConnector? _apiConnector;
|
||||
|
||||
public ThreemaService(TerminalLogger logger, JobManager jobManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "Threema Webhook Listener",
|
||||
Description = "Listens for incoming Threema Gateway Webhooks on the configured port.",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
_jobStatus.StatusText = "Manual trigger not supported for Webhook";
|
||||
await Task.Delay(2000);
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
InitConnector();
|
||||
}
|
||||
|
||||
public void ReloadSettings()
|
||||
{
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
InitConnector();
|
||||
}
|
||||
|
||||
private void InitConnector()
|
||||
{
|
||||
if (_settings.ThreemaEnabled && !string.IsNullOrEmpty(_settings.ThreemaGatewayId) && !string.IsNullOrEmpty(_settings.ThreemaSecret))
|
||||
{
|
||||
// Initialize the pt-icg SDK APIConnector
|
||||
_apiConnector = new APIConnector(_settings.ThreemaGatewayId, _settings.ThreemaSecret, new PublicKeyStoreNone());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendMessageAsync(string text, string parseMode = "")
|
||||
{
|
||||
if (!_settings.ThreemaEnabled || _apiConnector == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Using the GroupID field as the target (could be a Threema ID)
|
||||
string targetId = _settings.ThreemaGroupId;
|
||||
if (string.IsNullOrEmpty(targetId))
|
||||
{
|
||||
_logger.Error("Threema send failed: Target ID (Group ID) is not configured.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
// If no private key is configured, fallback to Basic mode (SendTextMessageSimple)
|
||||
// Note: Basic mode does not support actual Group messaging, so targetId must be a personal Threema ID.
|
||||
// If a private key IS configured, we would use E2E mode, but without the official 2.0 SDK's
|
||||
// SendGroupTextMessage, we just send a direct E2E message.
|
||||
|
||||
if (string.IsNullOrEmpty(_settings.ThreemaPrivateKey))
|
||||
{
|
||||
string msgId = _apiConnector.SendTextMessageSimple(targetId, text);
|
||||
if (!string.IsNullOrEmpty(msgId))
|
||||
{
|
||||
_logger.Info($"Threema basic message sent. ID: {msgId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// E2E Mode Direct Message
|
||||
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
|
||||
byte[] publicKey = _apiConnector.LookupKey(targetId);
|
||||
|
||||
if (publicKey == null)
|
||||
{
|
||||
_logger.Error($"Threema E2E failed: Could not lookup public key for {targetId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] nonce = CryptTool.RandomNonce();
|
||||
var encryptResult = CryptTool.EncryptTextMessage(text, privateKey, publicKey);
|
||||
|
||||
if (encryptResult != null && encryptResult.Result != null)
|
||||
{
|
||||
byte[] box = encryptResult.Result;
|
||||
string msgId = _apiConnector.SendE2EMessage(targetId, encryptResult.Nonce, box);
|
||||
if (!string.IsNullOrEmpty(msgId))
|
||||
{
|
||||
_logger.Info($"Threema E2E message sent. ID: {msgId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Threema send failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_jobStatus.StatusText = "Idle";
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_settings.ThreemaEnabled || !_jobStatus.IsEnabled)
|
||||
{
|
||||
_jobStatus.StatusText = "Paused / Disabled";
|
||||
if (_httpListener != null && _httpListener.IsListening)
|
||||
{
|
||||
_httpListener.Stop();
|
||||
}
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_httpListener == null || !_httpListener.IsListening)
|
||||
{
|
||||
_httpListener = new HttpListener();
|
||||
_httpListener.Prefixes.Add($"http://*:{_settings.ThreemaWebhookPort}/");
|
||||
_httpListener.Start();
|
||||
_jobStatus.StatusText = $"Listening on port {_settings.ThreemaWebhookPort}...";
|
||||
_logger.Info($"[Threema] Webhook listener started on port {_settings.ThreemaWebhookPort}");
|
||||
}
|
||||
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
|
||||
var getContextTask = _httpListener.GetContextAsync();
|
||||
var delayTask = Task.Delay(5000, stoppingToken);
|
||||
|
||||
var completedTask = await Task.WhenAny(getContextTask, delayTask);
|
||||
|
||||
if (completedTask == getContextTask)
|
||||
{
|
||||
var context = await getContextTask;
|
||||
_ = Task.Run(() => HandleIncomingWebhook(context), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is HttpListenerException hle && hle.ErrorCode == 5)
|
||||
{
|
||||
_logger.Error($"[Threema] Access Denied starting Webhook. Try running as Administrator or run: netsh http add urlacl url=http://*:{_settings.ThreemaWebhookPort}/ user=Everyone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"[Threema] Listener error: {ex.Message}");
|
||||
}
|
||||
|
||||
_jobStatus.StatusText = "Error! Retrying in 5s...";
|
||||
if (_httpListener != null)
|
||||
{
|
||||
try { _httpListener.Close(); } catch { }
|
||||
_httpListener = null;
|
||||
}
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
|
||||
_jobStatus.NextRun = DateTime.Now;
|
||||
}
|
||||
|
||||
if (_httpListener != null)
|
||||
{
|
||||
try { _httpListener.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleIncomingWebhook(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
|
||||
if (request.HttpMethod == "POST")
|
||||
{
|
||||
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
|
||||
{
|
||||
string body = reader.ReadToEnd();
|
||||
var parsedParams = System.Web.HttpUtility.ParseQueryString(body);
|
||||
|
||||
string? from = parsedParams["from"];
|
||||
string? to = parsedParams["to"];
|
||||
string? nonceStr = parsedParams["nonce"];
|
||||
string? boxStr = parsedParams["box"];
|
||||
string? macStr = parsedParams["mac"];
|
||||
|
||||
// Decrypt the message if E2E
|
||||
if (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(nonceStr) && !string.IsNullOrEmpty(boxStr) && !string.IsNullOrEmpty(_settings.ThreemaPrivateKey) && _apiConnector != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
|
||||
byte[] publicKey = _apiConnector.LookupKey(from);
|
||||
byte[] nonce = DataUtils.HexStringToByteArray(nonceStr);
|
||||
byte[] box = DataUtils.HexStringToByteArray(boxStr);
|
||||
|
||||
if (publicKey != null)
|
||||
{
|
||||
var msg = CryptTool.DecryptMessage(box, privateKey, publicKey, nonce);
|
||||
if (msg is IcgSoftware.Threema.CoreMsgApi.Messages.TextMessage textMsg)
|
||||
{
|
||||
string text = textMsg.Text;
|
||||
string logText = text.Length > 200 ? text.Substring(0, 200) + "..." : text;
|
||||
_logger.Info($"[Threema] Empfangen von {from}: {logText}");
|
||||
|
||||
if (text.StartsWith("/"))
|
||||
{
|
||||
OnCommandReceived?.Invoke(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception dex)
|
||||
{
|
||||
_logger.Warning($"[Threema] Failed to decrypt incoming message: {dex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info($"[Threema] Received webhook, but cannot process (Basic mode doesn't support incoming, or missing E2E keys).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.StatusCode = 200;
|
||||
response.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"[Threema] Webhook handling error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user