Skip to content

Commit f95a6c2

Browse files
feat: Enhance build process and add test execution
- Added a step to run tests in the GitHub Actions build workflow. - Introduced publishing for the UpdateAgent project in the build workflow. - Copied install scripts during the build process. refactor: Improve process checking in WarningPopup - Refactored process checking logic to use a dedicated method for better readability and resource management. docs: Update security notes in README - Expanded security notes regarding remote access and TLS requirements for the dashboard listener. fix: Ensure proper disposal of process objects in DiscoveryService and LimitEnforcer - Added finally blocks to dispose of process objects to prevent resource leaks. feat: Implement input validation utility - Created InputValidation class to centralize validation logic for app names, process names, and other inputs. fix: Enhance email command processing in EmailService - Improved command processing logic to handle email commands more robustly and added validation for app names and limits. feat: Add session management for admin password in dashboard - Implemented session management for admin password prompts in the dashboard JavaScript. refactor: Optimize DashboardServer for better performance - Refactored DashboardServer to improve token management and streamline API responses.
1 parent 83dc23b commit f95a6c2

14 files changed

Lines changed: 486 additions & 110 deletions

File tree

.github/workflows/build.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
- name: Build (Release)
2525
run: dotnet build .\MonitorAndControl.csproj -c Release --no-restore
2626

27+
- name: Run tests
28+
run: dotnet run --project .\Tests\MonitorAndControl.Tests.csproj -c Release
29+
2730
- name: Publish Monitor
2831
run: dotnet publish .\MonitorAndControl.csproj -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o publish
2932

@@ -33,6 +36,15 @@ jobs:
3336
- name: Publish PopupHost
3437
run: dotnet publish .\PopupHost\PopupHost.csproj -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o publish
3538

39+
- name: Publish UpdateAgent
40+
run: dotnet publish .\UpdateAgent\UpdateAgent.csproj -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -o publish
41+
42+
- name: Copy install scripts
43+
shell: pwsh
44+
run: |
45+
Copy-Item .\install-watchdog.ps1 publish\
46+
Copy-Item .\uninstall-watchdog.ps1 publish\
47+
3648
- name: Upload artifacts
3749
uses: actions/upload-artifact@v4
3850
with:

PopupHost/WarningPopup.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,7 @@ public WarningPopup(
112112
_checkTimer = new System.Windows.Forms.Timer { Interval = 2000 };
113113
_checkTimer.Tick += (s, e) =>
114114
{
115-
if (!string.IsNullOrEmpty(_processName) &&
116-
Process.GetProcessesByName(Path.GetFileNameWithoutExtension(_processName)).Length == 0)
115+
if (!string.IsNullOrEmpty(_processName) && !IsProcessRunning(_processName))
117116
{
118117
_checkTimer.Stop();
119118
_timer.Stop();
@@ -149,6 +148,17 @@ public WarningPopup(
149148
}
150149
}
151150

151+
private static bool IsProcessRunning(string processName)
152+
{
153+
var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(processName));
154+
try { return processes.Length > 0; }
155+
finally
156+
{
157+
foreach (var process in processes)
158+
process.Dispose();
159+
}
160+
}
161+
152162
private string FormatCountdown(int seconds) => FormatTemplate(_closingTemplate, seconds);
153163

154164
private string FormatTemplate(string template, int seconds) =>

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Place this file alongside `DeviceMon.exe`. All settings are optional — default
282282
| `DefaultLimits` | `[]` | Daily limits imported only when the database is first created |
283283
| `Schedule` | `[]` | Allowed-hours rules imported only when the database is first created |
284284

285-
> **Security note:** When remote access is enabled, remote users cannot open the dashboard until an admin password has been created from a trusted local dashboard. After setup, remote users are redirected to the login page. Successful authentication creates an HTTP-only session cookie; use **Settings → Logout** to end that browser session.
285+
> **Security note:** When remote access is enabled, remote users cannot open the dashboard until an admin password has been created from a trusted local dashboard. After setup, remote users are redirected to the login page. Successful authentication creates an HTTP-only session cookie; use **Settings → Logout** to end that browser session. The built-in dashboard listener is HTTP only; do not expose it beyond a trusted LAN unless you put it behind a TLS reverse proxy, VPN/tunnel, or a trusted local certificate setup.
286286
287287
### Installing the Watchdog (optional, requires admin)
288288

@@ -331,6 +331,15 @@ Once installed, the `GameHost` service runs under the SYSTEM account and automat
331331

332332
**Remote access:** Settings show the computer name and IP addresses. From another PC, open `http://CHILD_PC_NAME:5000` or `http://192.168.x.x:5000`.
333333

334+
**LAN HTTPS:** HTTPS always requires a certificate. For a local LAN, use one of these approaches:
335+
336+
- Put DeviceMon behind a reverse proxy with a certificate trusted by the parent devices.
337+
- Use a real domain name that resolves to the LAN IP and issue a certificate with DNS validation.
338+
- Use a private CA/self-signed certificate and install that CA/certificate as trusted on every parent device.
339+
- Use a VPN/tunnel product that provides HTTPS at its edge.
340+
341+
Without one of those trust models, browsers will show certificate warnings because they cannot verify the local server identity.
342+
334343
### Email control (optional)
335344

336345
1. Enable 2‑Factor Authentication on your Gmail account

Services/DiscoveryService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ private void ScanRunningProcesses(Dictionary<string, DiscoveredApp> results)
123123
results.TryAdd(procName, new DiscoveredApp(procName, title, path, "Running"));
124124
}
125125
catch { }
126+
finally
127+
{
128+
proc.Dispose();
129+
}
126130
}
127131
}
128132
catch { }

Services/EmailService.cs

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public class EmailService : IDisposable
3535
private string _allowedSender = "";
3636
private string _deviceId = Environment.MachineName;
3737
private string _startAlertDate = DateTime.Now.ToString("yyyy-MM-dd");
38+
private const int MaxCommandScanMessages = 25;
3839
private const string SmtpHost = "smtp.gmail.com";
3940
private const int SmtpPort = 587;
4041
private const string ImapHost = "imap.gmail.com";
@@ -234,31 +235,31 @@ private async void PollInbox(object? state)
234235
// be used as command-delivery state because the first computer would hide a
235236
// broadcast from all the others. Each installation keeps its own receipt list.
236237
var allUids = await client.Inbox.SearchAsync(fromQuery);
237-
var uids = allUids.Skip(Math.Max(0, allUids.Count - 250)).ToList();
238+
var uids = allUids.Skip(Math.Max(0, allUids.Count - MaxCommandScanMessages)).ToList();
238239
if (uids.Count == 0) { await client.DisconnectAsync(true); return; }
239240

240-
var items = await client.Inbox.FetchAsync(uids, new MailKit.FetchRequest(MailKit.MessageSummaryItems.Full));
241+
var summaryItems =
242+
MailKit.MessageSummaryItems.UniqueId |
243+
MailKit.MessageSummaryItems.Envelope |
244+
MailKit.MessageSummaryItems.Flags |
245+
MailKit.MessageSummaryItems.InternalDate;
246+
var items = await client.Inbox.FetchAsync(uids, new MailKit.FetchRequest(summaryItems));
241247
var initialized = (await _db.GetSettingAsync("EmailCommandTrackingInitialized", "false")) == "true";
242248
foreach (var item in items)
243249
{
244-
var mime = await client.Inbox.GetMessageAsync(item.UniqueId);
245-
var from = mime.From.Mailboxes.FirstOrDefault()?.Address ?? "";
246-
var subject = mime.Subject ?? "";
247-
var body = (mime.TextBody ?? mime.HtmlBody ?? "").Trim();
250+
var from = item.Envelope?.From.Mailboxes.FirstOrDefault()?.Address ?? "";
251+
var subject = item.Envelope?.Subject ?? "";
248252
if (!allowedSenders.Contains(from, StringComparer.OrdinalIgnoreCase))
249253
continue;
250254

251-
var commandText = ExtractCommandText(subject, body);
252-
if (commandText == null)
253-
continue;
254-
255-
var messageKey = !string.IsNullOrWhiteSpace(mime.MessageId)
256-
? $"message-id:{mime.MessageId.Trim()}"
255+
var messageId = item.Envelope?.MessageId;
256+
var messageKey = !string.IsNullOrWhiteSpace(messageId)
257+
? $"message-id:{messageId.Trim()}"
257258
: $"imap:{client.Inbox.UidValidity}:{item.UniqueId.Id}";
258259
if (await _db.IsEmailCommandProcessedAsync(messageKey))
259260
continue;
260261

261-
var receivedAt = item.InternalDate ?? mime.Date;
262+
var receivedAt = item.InternalDate ?? item.Envelope?.Date ?? DateTimeOffset.UtcNow;
262263
var tooOld = receivedAt < DateTimeOffset.UtcNow.AddDays(-30);
263264
var predatesUpgrade = !initialized
264265
&& item.Flags.HasValue
@@ -270,6 +271,18 @@ private async void PollInbox(object? state)
270271
continue;
271272
}
272273

274+
var subjectCommand = ExtractCommandText(subject, "");
275+
var mime = subjectCommand == null
276+
? await client.Inbox.GetMessageAsync(item.UniqueId)
277+
: null;
278+
var body = mime == null ? "" : (mime.TextBody ?? mime.HtmlBody ?? "").Trim();
279+
var commandText = subjectCommand ?? ExtractCommandText(subject, body);
280+
if (commandText == null)
281+
{
282+
await _db.MarkEmailCommandProcessedAsync(messageKey);
283+
continue;
284+
}
285+
273286
if (!IsCommandForThisDevice(commandText, out var effectiveCommand))
274287
{
275288
Logger.Instance.Info($"Email command ignored on {_deviceId}; target does not match: \"{commandText}\"");
@@ -429,8 +442,11 @@ set kill-delay [seconds] - set kill delay
429442
var setMatch = Regex.Match(line, @"^set\s+(.+?)\s+(\d+)\s*min", RegexOptions.IgnoreCase);
430443
if (setMatch.Success)
431444
{
432-
var appName = setMatch.Groups[1].Value.Trim();
445+
var appName = InputValidation.Clean(setMatch.Groups[1].Value);
433446
var minutes = int.Parse(setMatch.Groups[2].Value);
447+
if (!InputValidation.IsValidAppName(appName) || !InputValidation.IsValidLimitMinutes(minutes))
448+
return "Error: Limit requires a valid app name and 1-1440 minutes.";
449+
434450
await _db.SaveLimitRuleAsync(new AppLimitRule
435451
{
436452
AppName = appName,
@@ -445,10 +461,10 @@ await _db.SaveLimitRuleAsync(new AppLimitRule
445461
var bonusMatch = Regex.Match(line, @"^(?:bonus|extend)\s+(.+?)\s+(\d+)\s*min", RegexOptions.IgnoreCase);
446462
if (bonusMatch.Success)
447463
{
448-
var appName = bonusMatch.Groups[1].Value.Trim();
464+
var appName = InputValidation.Clean(bonusMatch.Groups[1].Value);
449465
var minutes = int.Parse(bonusMatch.Groups[2].Value);
450-
if (minutes is < 1 or > 240)
451-
return "Error: Bonus minutes must be between 1 and 240.";
466+
if (!InputValidation.IsValidAppName(appName) || !InputValidation.IsValidBonusMinutes(minutes))
467+
return "Error: Bonus requires a valid app name and 1-240 minutes.";
452468

453469
var limits = await _db.GetLimitRulesAsync();
454470
if (!limits.Any(l => l.AppName.Equals(appName, StringComparison.OrdinalIgnoreCase)))
@@ -462,7 +478,10 @@ await _db.SaveLimitRuleAsync(new AppLimitRule
462478
var bedtimeMatch = Regex.Match(line, @"^(?:bonus|extend|allow)\s+(.+?)\s+(?:until\s+)?bedtime$", RegexOptions.IgnoreCase);
463479
if (bedtimeMatch.Success)
464480
{
465-
var appName = bedtimeMatch.Groups[1].Value.Trim();
481+
var appName = InputValidation.Clean(bedtimeMatch.Groups[1].Value);
482+
if (!InputValidation.IsValidAppName(appName))
483+
return "Error: Invalid app name.";
484+
466485
var limits = await _db.GetLimitRulesAsync();
467486
var limit = limits.FirstOrDefault(l => l.AppName.Equals(appName, StringComparison.OrdinalIgnoreCase));
468487
if (limit == null)
@@ -491,17 +510,23 @@ await _db.SaveLimitRuleAsync(new AppLimitRule
491510
{
492511
var day = schedMatch.Groups[1].Value;
493512
day = char.ToUpper(day[0]) + day.Substring(1).ToLower();
513+
var startTime = schedMatch.Groups[2].Value;
514+
var endTime = schedMatch.Groups[3].Value;
515+
if (!TimeSpan.TryParse(startTime, out var start) || start < TimeSpan.Zero || start >= TimeSpan.FromDays(1) ||
516+
!TimeSpan.TryParse(endTime, out var end) || end < TimeSpan.Zero || end >= TimeSpan.FromDays(1))
517+
return "Error: Invalid schedule time.";
518+
494519
if (day is "Weekday" or "Weekend" or "Everyday" or "Monday" or "Tuesday" or "Wednesday" or "Thursday" or "Friday" or "Saturday" or "Sunday")
495520
{
496521
await _db.SaveScheduleRuleAsync(new ScheduleRule
497522
{
498523
DayOfWeek = day,
499-
StartTime = schedMatch.Groups[2].Value,
500-
EndTime = schedMatch.Groups[3].Value,
524+
StartTime = startTime,
525+
EndTime = endTime,
501526
Enabled = true
502527
});
503528
_scheduler.InvalidateCache();
504-
return $"OK: Schedule added: {day} {schedMatch.Groups[2].Value}-{schedMatch.Groups[3].Value}";
529+
return $"OK: Schedule added: {day} {startTime}-{endTime}";
505530
}
506531
return $"Error: Invalid day: {day}. Use: Weekday, Weekend, Everyday, or day name.";
507532
}
@@ -511,6 +536,9 @@ await _db.SaveScheduleRuleAsync(new ScheduleRule
511536
if (delayMatch.Success)
512537
{
513538
var secs = int.Parse(delayMatch.Groups[1].Value);
539+
if (!InputValidation.IsValidKillDelaySeconds(secs))
540+
return "Error: Kill delay must be between 5 and 300 seconds.";
541+
514542
await _db.SetKillDelayAsync(secs);
515543
return $"OK: Kill delay set to {secs}s";
516544
}
@@ -519,8 +547,11 @@ await _db.SaveScheduleRuleAsync(new ScheduleRule
519547
var addMatch = Regex.Match(line, @"^add\s+(\S+)\s+(.+)", RegexOptions.IgnoreCase);
520548
if (addMatch.Success)
521549
{
522-
var proc = addMatch.Groups[1].Value;
523-
var name = addMatch.Groups[2].Value.Trim();
550+
var proc = InputValidation.Clean(addMatch.Groups[1].Value);
551+
var name = InputValidation.Clean(addMatch.Groups[2].Value);
552+
if (!InputValidation.IsValidProcessName(proc) || !InputValidation.IsValidAppName(name))
553+
return "Error: Invalid process or app name.";
554+
524555
var existing = (await _db.GetAppMappingsAsync()).FirstOrDefault(mapping =>
525556
mapping.ProcessName.Equals(proc, StringComparison.OrdinalIgnoreCase));
526557
var countInBackground = existing?.CountInBackground ?? false;

Services/InputValidation.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace MonitorAndControl.Services;
2+
3+
public static class InputValidation
4+
{
5+
public static string Clean(string? value) => (value ?? "").Trim();
6+
7+
public static bool IsValidAppName(string value) =>
8+
value.Length is > 0 and <= 120 && !value.Any(char.IsControl);
9+
10+
public static bool IsValidProcessName(string value) =>
11+
value.Length is > 4 and <= 260 &&
12+
value.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) &&
13+
Path.GetFileName(value).Equals(value, StringComparison.OrdinalIgnoreCase) &&
14+
!value.Any(char.IsControl);
15+
16+
public static bool IsValidLimitMinutes(int minutes) => minutes is >= 1 and <= 1440;
17+
18+
public static bool IsValidBonusMinutes(int minutes) => minutes is >= 1 and <= 240;
19+
20+
public static bool IsValidKillDelaySeconds(int seconds) => seconds is >= 5 and <= 300;
21+
22+
public static bool IsValidHttpUrl(string value) =>
23+
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
24+
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
25+
}

Services/LimitEnforcer.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ private void KillProcessByName(string processName)
366366
{
367367
Logger.Instance.Error($"Failed to close process {proc.ProcessName} ({proc.Id}): {ex.Message}");
368368
}
369+
finally
370+
{
371+
proc.Dispose();
372+
}
369373
}
370374
}
371375
catch (Exception ex)

Services/NotificationService.cs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,18 @@ private async Task<bool> FireWebhook(string eventType, string appName, object pa
6565
{
6666
var url = await GetWebhookUrlAsync();
6767
if (string.IsNullOrWhiteSpace(url)) return false;
68+
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
69+
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) ||
70+
await IsBlockedWebhookTargetAsync(uri))
71+
{
72+
Logger.Instance.Warn($"Blocked unsafe webhook target: {url}");
73+
return false;
74+
}
6875

6976
var json = JsonSerializer.Serialize(payload, JsonOpts);
7077
using var content = new StringContent(json, Encoding.UTF8, "application/json");
7178

72-
using var response = await _http.PostAsync(url, content);
79+
using var response = await _http.PostAsync(uri, content);
7380
if (!response.IsSuccessStatusCode)
7481
{
7582
System.Diagnostics.Debug.WriteLine(
@@ -84,6 +91,44 @@ private async Task<bool> FireWebhook(string eventType, string appName, object pa
8491
}
8592
}
8693

94+
private static async Task<bool> IsBlockedWebhookTargetAsync(Uri uri)
95+
{
96+
if (uri.IsLoopback || uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
97+
return true;
98+
99+
try
100+
{
101+
var addresses = await System.Net.Dns.GetHostAddressesAsync(uri.Host);
102+
return addresses.Length == 0 || addresses.Any(IsLocalOnlyAddress);
103+
}
104+
catch
105+
{
106+
return true;
107+
}
108+
}
109+
110+
private static bool IsLocalOnlyAddress(System.Net.IPAddress address)
111+
{
112+
if (System.Net.IPAddress.IsLoopback(address) ||
113+
address.Equals(System.Net.IPAddress.Any) ||
114+
address.Equals(System.Net.IPAddress.IPv6Any) ||
115+
address.Equals(System.Net.IPAddress.Broadcast) ||
116+
address.IsIPv6LinkLocal ||
117+
address.IsIPv6SiteLocal)
118+
return true;
119+
120+
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
121+
{
122+
var bytes = address.GetAddressBytes();
123+
return (bytes[0] & 0xfe) == 0xfc;
124+
}
125+
126+
var b = address.GetAddressBytes();
127+
return b[0] == 0 ||
128+
b[0] == 127 ||
129+
(b[0] == 169 && b[1] == 254);
130+
}
131+
87132
public async Task TestWebhookAsync()
88133
{
89134
await FireWebhook("test", "System", new

0 commit comments

Comments
 (0)