Merge wj: fleet alert notifications and operator report emails
Brings in the fleet alert notification concept, the operator report email alerts, and the tray work that goes with them (tray 1.6.5). Two files needed resolution: - ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all tray-coordination bumps with no agent code behind them. Ours carried 1.14.0, which is real code. Kept every changelog entry and took 1.14.0 as the version, since it already supersedes the 1.13.x line. - plugins/endpoints/api/routes.py auto-merged; verified the errormode descriptor survived intact. KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge - authz on endpoints.acknowledge_fleet_alert, the public GET surface scan, two plugin-contract import checks, and the notifications plugin migration/model agreement. All five were confirmed failing on a clean worktree of origin/wj before merging, so they arrive with the branch rather than from the merge. They reflect the fleet alert work being half-landed: the push path was a delivery proof of concept and the server-side surface was still being stood up. These are not fixed here on purpose. The next change replaces that delivery path with the endpoint agent, so the notification persists on the endpoint until the operator acknowledges it or the server closes it, and the affected surfaces get their gating and migration settled as part of that work rather than being patched twice.
This commit is contained in:
13
.mcp.json
Normal file
13
.mcp.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shopdb": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"C:/Users/270015376/Documents/github/shopdb-mcp-server/index.js"
|
||||
],
|
||||
"env": {
|
||||
"SHOPDB_REPO_PATH": "C:/Users/270015376/Documents/github/flask-shopdb"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
216
DARKWING-FINDINGS.md
Normal file
216
DARKWING-FINDINGS.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Darkwing Security Findings - 2026-09-07
|
||||
|
||||
**Scan Run:** 34093911764 (wj branch)
|
||||
**Findings:** 2 (1 High, 1 Medium)
|
||||
**Status:** Patches ready to apply
|
||||
|
||||
---
|
||||
|
||||
## Finding 1: [High] Unauthenticated Admin Bootstrap Race
|
||||
|
||||
**ID:** `setup-create-admin-unauthenticated`
|
||||
**File:** `shopdb/core/api/setup.py:39`
|
||||
|
||||
### Problem
|
||||
`POST /api/setup/create-admin` has no authentication. Between container start and first admin creation, any network caller can claim the instance. Every documented deploy path has this window:
|
||||
|
||||
```bash
|
||||
docker-compose up -d # API listening
|
||||
flask db upgrade # Migrations run
|
||||
# <-- RACE WINDOW: anyone can POST /api/setup/create-admin
|
||||
flask seed admin # Operator creates admin
|
||||
```
|
||||
|
||||
### Impact
|
||||
- Total control: `admin` role bypasses all permission checks
|
||||
- No password strength requirement (1-char accepted)
|
||||
- No audit trail (creation is silent)
|
||||
- Window reopens on restore to empty schema
|
||||
|
||||
### Fix Applied
|
||||
Requires **proof of operator control**:
|
||||
|
||||
**Option A: Setup token** (remote install)
|
||||
```bash
|
||||
export SETUP_TOKEN=$(openssl rand -hex 32)
|
||||
curl -X POST http://host/api/setup/create-admin \
|
||||
-H "X-Setup-Token: $SETUP_TOKEN" \
|
||||
-d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}'
|
||||
```
|
||||
|
||||
**Option B: Loopback** (local install)
|
||||
```bash
|
||||
# From inside container or via SSH to host
|
||||
curl -X POST http://localhost:5000/api/setup/create-admin \
|
||||
-d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}'
|
||||
```
|
||||
|
||||
**Option C: CLI** (unchanged, always works)
|
||||
```bash
|
||||
docker exec -it shopdb-api flask seed admin
|
||||
```
|
||||
|
||||
### Additional Hardening
|
||||
- 12-character password minimum enforced
|
||||
- Cross-checks `setup_complete` setting
|
||||
- Logs "First administrator 'X' created from Y.Y.Y.Y"
|
||||
- Token printed to container logs on first startup
|
||||
- New CLI helper: `flask seed show-setup-token`
|
||||
|
||||
### Recovery Paths
|
||||
Lost the token? Three options:
|
||||
1. `docker logs shopdb-api | grep "Setup token"`
|
||||
2. `docker exec shopdb-api flask seed show-setup-token`
|
||||
3. `docker exec -it shopdb-api flask seed admin` (just works)
|
||||
|
||||
---
|
||||
|
||||
## Finding 2: [Medium] Unauthenticated KB Article Disclosure
|
||||
|
||||
**ID:** `kb-click-unauth-linkurl-disclosure`
|
||||
**File:** `plugins/knowledgebase/api/routes.py:182`
|
||||
|
||||
### Problem
|
||||
`POST /api/knowledgebase/<id>/click` has `@jwt_required(optional=True)` instead of `@require_permission('kb.view')`. Anonymous callers can:
|
||||
- Enumerate article IDs 1..N and harvest link URLs
|
||||
- Bypass visibility filter (retired topics exposed)
|
||||
- Inflate click counters unbounded (DB write spam)
|
||||
- URLs may contain tokens/keys in query strings
|
||||
|
||||
Compare with sibling route:
|
||||
```python
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
||||
@require_permission('kb.view') # <-- gated
|
||||
def get_article(link_id: int):
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
||||
@jwt_required(optional=True) # <-- NOT gated
|
||||
def track_click(link_id: int):
|
||||
return success_response({'linkurl': article.linkurl, ...}) # <-- leaks URL
|
||||
```
|
||||
|
||||
### Impact
|
||||
- Leaks internal documentation URLs (SharePoint, Confluence, etc.)
|
||||
- URLs may contain auth tokens in query strings
|
||||
- Unauthenticated write primitive (DB transactions)
|
||||
- Explicitly exempted from authz sweep in tests
|
||||
|
||||
### Fix Applied
|
||||
- Gate on `kb.view` like other KB reads
|
||||
- Remove `linkurl` from response (caller already has it)
|
||||
- Apply same `_visible_articles()` filter as list/detail
|
||||
- Counter-only response: `{'clicks': N}`
|
||||
|
||||
---
|
||||
|
||||
## Applying the Patch
|
||||
|
||||
```bash
|
||||
cd /c/Users/270015376/Documents/github/flask-shopdb
|
||||
git apply darkwing-fixes.patch
|
||||
```
|
||||
|
||||
Or review and apply manually.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test 1: Admin bootstrap now requires token
|
||||
```bash
|
||||
# Without token - should fail
|
||||
curl -X POST http://localhost:5000/api/setup/create-admin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"attacker","email":"a@b.c","password":"SecurePass123"}'
|
||||
# Expected: 403 "requires the setup token"
|
||||
|
||||
# With token - should succeed
|
||||
curl -X POST http://localhost:5000/api/setup/create-admin \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Setup-Token: $SETUP_TOKEN" \
|
||||
-d '{"username":"admin","email":"admin@example.com","password":"SecurePass123"}'
|
||||
# Expected: 201 Created
|
||||
```
|
||||
|
||||
### Test 2: Password minimum enforced
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/setup/create-admin \
|
||||
-H "X-Setup-Token: $SETUP_TOKEN" \
|
||||
-d '{"username":"admin","email":"a@b.c","password":"short"}'
|
||||
# Expected: 400 "password must be at least 12 characters"
|
||||
```
|
||||
|
||||
### Test 3: KB click gated
|
||||
```bash
|
||||
# Without auth - should fail
|
||||
curl -X POST http://localhost:5000/api/knowledgebase/1/click
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
# With auth - should succeed (no linkurl in response)
|
||||
curl -X POST http://localhost:5000/api/knowledgebase/1/click \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
# Expected: {"status":"success","data":{"clicks":N}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deploy Workflow Changes
|
||||
|
||||
### Current (Unsafe)
|
||||
```powershell
|
||||
docker-compose up -d
|
||||
docker exec shopdb-api flask db upgrade
|
||||
# <-- race window
|
||||
docker exec shopdb-api flask seed admin
|
||||
```
|
||||
|
||||
### After Patch (Safe)
|
||||
```powershell
|
||||
# Generate token first
|
||||
$token = openssl rand -hex 32
|
||||
echo "SETUP_TOKEN=$token" >> .env
|
||||
|
||||
docker-compose up -d
|
||||
# Token logged to console: "Setup token: abc123..."
|
||||
|
||||
docker exec shopdb-api flask db upgrade
|
||||
docker exec shopdb-api flask seed admin
|
||||
# OR: curl with X-Setup-Token header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Before Patch
|
||||
- **Admin Bootstrap:** HIGH - Network race, no password requirements, silent
|
||||
- **KB Disclosure:** MEDIUM - Leaks URLs, unauthenticated writes, visibility bypass
|
||||
|
||||
### After Patch
|
||||
- **Admin Bootstrap:** LOW - Requires token or localhost, 12-char password, audited
|
||||
- **KB Disclosure:** LOW - Gated on permission like other reads
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
1. `shopdb/__init__.py` - Token logging on first startup
|
||||
2. `shopdb/cli/__init__.py` - New `flask seed show-setup-token` command
|
||||
3. `shopdb/core/api/setup.py` - Token/loopback auth, password minimum, audit log
|
||||
4. `plugins/knowledgebase/api/routes.py` - Gate click route, remove linkurl
|
||||
|
||||
---
|
||||
|
||||
## Rollout Priority
|
||||
|
||||
**Recommendation:** Apply in next maintenance window
|
||||
|
||||
**Urgency:**
|
||||
- WJ (prod): **Medium** - Air-gapped, limited exposure, but closes audit finding
|
||||
- OPS (dev): **Low** - Internal network, can wait for next deploy
|
||||
- WILM (mirror): **Low** - Read-only mirror, no setup route active
|
||||
|
||||
**Compatibility:** Zero breaking changes
|
||||
- CLI `flask seed admin` unchanged
|
||||
- Existing authenticated workflows unaffected
|
||||
- Web wizard needs token in URL param (one-line frontend change)
|
||||
97
agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs
Normal file
97
agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Polls the fleet alert channel to fetch active notifications for this endpoint.
|
||||
// The server may publish site-wide alerts (maintenance windows, policy changes)
|
||||
// or endpoint-specific warnings (outdated agent version, failed telemetry push).
|
||||
// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle
|
||||
// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints
|
||||
// collector key via X-API-Key, so the alert channel rides the same kill switch as
|
||||
// telemetry and commands.
|
||||
public sealed class FleetAlertClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly SocketsHttpHandler handler;
|
||||
private readonly ILogger logger;
|
||||
|
||||
public FleetAlertClient(AgentConfig config, ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
handler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
public async Task<List<FleetAlert>> FetchAlertsAsync(
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
var url = "api/endpoints/alerts?hostname="
|
||||
+ Uri.EscapeDataString(hostname);
|
||||
using var response = await http.GetAsync(url, token);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return new List<FleetAlert>();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
|
||||
body, JsonOptions);
|
||||
return envelope?.Data ?? new List<FleetAlert>();
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAlertAsync(
|
||||
int notificationId, int assetId, string hostname, CancellationToken token)
|
||||
{
|
||||
var url = $"api/endpoints/alerts/{notificationId}/acknowledge";
|
||||
var payload = new
|
||||
{
|
||||
assetid = assetId,
|
||||
hostname
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await http.PostAsync(url, content, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
public sealed class FleetAlert
|
||||
{
|
||||
[JsonPropertyName("notificationId")] public int NotificationId { get; set; }
|
||||
[JsonPropertyName("type")] public string TypeName { get; set; } = "";
|
||||
[JsonPropertyName("typeColor")] public string TypeColor { get; set; } = "";
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||
[JsonPropertyName("timeout")] public int Timeout { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FleetAlertEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
[JsonPropertyName("data")] public List<FleetAlert>? Data { get; set; }
|
||||
}
|
||||
113
agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs
Normal file
113
agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Local JSON file store for tracking acknowledged fleet alerts.
|
||||
// Stores notification IDs that have been acknowledged so they don't
|
||||
// reappear in future polls. Thread-safe file access with JSON serialization.
|
||||
public sealed class FleetAlertStore
|
||||
{
|
||||
private static readonly string DataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDbAgent");
|
||||
|
||||
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
|
||||
|
||||
private readonly object fileLock = new();
|
||||
private readonly ILogger logger;
|
||||
private HashSet<int> acknowledged;
|
||||
|
||||
public FleetAlertStore(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
acknowledged = new HashSet<int>();
|
||||
Load();
|
||||
}
|
||||
|
||||
public bool IsAcknowledged(int notificationId)
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
return acknowledged.Contains(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkAcknowledged(int notificationId)
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
if (acknowledged.Add(notificationId))
|
||||
{
|
||||
Save();
|
||||
PruneOld();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(StoreFile))
|
||||
{
|
||||
acknowledged = new HashSet<int>();
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(StoreFile);
|
||||
var stored = JsonSerializer.Deserialize<StoredData>(json);
|
||||
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
|
||||
acknowledged = new HashSet<int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DataDir);
|
||||
|
||||
var data = new StoredData
|
||||
{
|
||||
AcknowledgedIds = acknowledged.ToList(),
|
||||
LastUpdated = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
File.WriteAllText(StoreFile, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not save acknowledged alerts store");
|
||||
}
|
||||
}
|
||||
|
||||
private void PruneOld()
|
||||
{
|
||||
// Keep only last 1000 acknowledged IDs to prevent unbounded growth
|
||||
if (acknowledged.Count > 1000)
|
||||
{
|
||||
var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet();
|
||||
acknowledged = toKeep;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StoredData
|
||||
{
|
||||
public List<int> AcknowledgedIds { get; set; } = new();
|
||||
public DateTime LastUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
356
agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs
Normal file
356
agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Displays fleet-wide alert notifications from the ShopDB server in a toast-style
|
||||
// window. The form appears top-right, plays a system beep, and stays on top until
|
||||
// dismissed. The left border is colored based on alert type (parsed from the
|
||||
// TypeColor hex string from the database). The operator acknowledges the alert by
|
||||
// clicking the button or the X. Countdown timer shows auto-dismiss time if configured.
|
||||
internal sealed class FleetAlertForm : Form
|
||||
{
|
||||
private readonly FleetAlert alert;
|
||||
private readonly FleetAlertClient? client;
|
||||
private readonly string? hostname;
|
||||
private System.Windows.Forms.Timer? countdownTimer;
|
||||
private System.Windows.Forms.Timer? statusCheckTimer;
|
||||
private readonly CancellationTokenSource statusCheckCancellation = new();
|
||||
private int remainingSeconds;
|
||||
private Label? countdownLabel;
|
||||
private readonly List<Font> fonts = new();
|
||||
|
||||
public event EventHandler? OnAcknowledgeClicked;
|
||||
|
||||
public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null)
|
||||
{
|
||||
this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
|
||||
this.client = client;
|
||||
this.hostname = hostname;
|
||||
|
||||
Text = string.Empty;
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
TopMost = true;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
ClientSize = new Size(400, 280);
|
||||
BackColor = Color.White;
|
||||
|
||||
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
|
||||
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
|
||||
|
||||
BuildLayout();
|
||||
ApplyRoundedCorners();
|
||||
|
||||
Load += FleetAlertForm_Load;
|
||||
FormClosing += FleetAlertForm_FormClosing;
|
||||
}
|
||||
|
||||
private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
statusCheckTimer?.Stop();
|
||||
statusCheckCancellation.Cancel();
|
||||
}
|
||||
|
||||
private void FleetAlertForm_Load(object? sender, EventArgs e)
|
||||
{
|
||||
System.Media.SystemSounds.Beep.Play();
|
||||
|
||||
if (client != null && hostname != null)
|
||||
{
|
||||
StartStatusCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartStatusCheck()
|
||||
{
|
||||
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
|
||||
statusCheckTimer.Tick += StatusCheckTimer_Tick;
|
||||
statusCheckTimer.Start();
|
||||
}
|
||||
|
||||
private void StatusCheckTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (client == null || hostname == null)
|
||||
return;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false;
|
||||
|
||||
if (!stillActive)
|
||||
{
|
||||
BeginInvoke(new Action(() =>
|
||||
{
|
||||
statusCheckTimer?.Stop();
|
||||
Close();
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Form is closing, ignore
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors - keep the form open if we can't check status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyRoundedCorners()
|
||||
{
|
||||
using var path = new GraphicsPath();
|
||||
int radius = 8;
|
||||
var rect = new Rectangle(0, 0, Width, Height);
|
||||
|
||||
path.AddArc(rect.X, rect.Y, radius, radius, 180, 90);
|
||||
path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90);
|
||||
path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90);
|
||||
path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90);
|
||||
path.CloseFigure();
|
||||
|
||||
Region = new Region(path);
|
||||
}
|
||||
|
||||
private void StartCountdown()
|
||||
{
|
||||
remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300;
|
||||
|
||||
countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 };
|
||||
countdownTimer.Tick += CountdownTimer_Tick;
|
||||
countdownTimer.Start();
|
||||
|
||||
UpdateCountdownDisplay();
|
||||
}
|
||||
|
||||
private void CountdownTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
remainingSeconds--;
|
||||
UpdateCountdownDisplay();
|
||||
|
||||
if (remainingSeconds <= 0)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCountdownDisplay()
|
||||
{
|
||||
if (countdownLabel != null)
|
||||
{
|
||||
int minutes = remainingSeconds / 60;
|
||||
int seconds = remainingSeconds % 60;
|
||||
countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}";
|
||||
}
|
||||
}
|
||||
|
||||
private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular)
|
||||
{
|
||||
var font = new Font(family, size, style);
|
||||
fonts.Add(font);
|
||||
return font;
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var borderPanel = new Panel
|
||||
{
|
||||
BackColor = ParseTypeColor(alert.TypeColor),
|
||||
Dock = DockStyle.Left,
|
||||
Width = 5,
|
||||
};
|
||||
Controls.Add(borderPanel);
|
||||
|
||||
var contentPanel = new Panel
|
||||
{
|
||||
Left = 5,
|
||||
Top = 0,
|
||||
Width = 395,
|
||||
Height = 280,
|
||||
BackColor = Color.White,
|
||||
};
|
||||
Controls.Add(contentPanel);
|
||||
|
||||
var headerPanel = new Panel
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Width = 395,
|
||||
Height = 45,
|
||||
BackColor = Color.FromArgb(245, 245, 245),
|
||||
};
|
||||
contentPanel.Controls.Add(headerPanel);
|
||||
|
||||
var typeLabel = new Label
|
||||
{
|
||||
Text = alert.TypeName.ToUpper(),
|
||||
Left = 16,
|
||||
Top = 14,
|
||||
Width = 300,
|
||||
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(51, 51, 51),
|
||||
};
|
||||
headerPanel.Controls.Add(typeLabel);
|
||||
|
||||
var closeButton = new Button
|
||||
{
|
||||
Text = "×",
|
||||
Left = 358,
|
||||
Top = 10,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Font = CreateFont("Segoe UI", 14),
|
||||
ForeColor = Color.FromArgb(102, 102, 102),
|
||||
BackColor = Color.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
closeButton.FlatAppearance.BorderSize = 0;
|
||||
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
|
||||
closeButton.Click += CloseButton_Click;
|
||||
headerPanel.Controls.Add(closeButton);
|
||||
|
||||
var titleLabel = new Label
|
||||
{
|
||||
Text = alert.Title,
|
||||
Left = 20,
|
||||
Top = 58,
|
||||
Width = 360,
|
||||
Height = 50,
|
||||
Font = CreateFont("Segoe UI", 11, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(51, 51, 51),
|
||||
AutoSize = false,
|
||||
};
|
||||
contentPanel.Controls.Add(titleLabel);
|
||||
|
||||
var messageLabel = new Label
|
||||
{
|
||||
Text = alert.Message,
|
||||
Left = 20,
|
||||
Top = 115,
|
||||
Width = 360,
|
||||
Height = 80,
|
||||
Font = CreateFont("Segoe UI", 9.75f),
|
||||
ForeColor = Color.FromArgb(85, 85, 85),
|
||||
AutoSize = false,
|
||||
};
|
||||
contentPanel.Controls.Add(messageLabel);
|
||||
|
||||
var acknowledgeButton = new Button
|
||||
{
|
||||
Text = "Acknowledge",
|
||||
Left = 130,
|
||||
Top = 202,
|
||||
Width = 140,
|
||||
Height = 32,
|
||||
BackColor = Color.FromArgb(76, 175, 80),
|
||||
ForeColor = Color.White,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Font = CreateFont("Segoe UI", 9, FontStyle.Bold),
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
acknowledgeButton.FlatAppearance.BorderSize = 0;
|
||||
acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73);
|
||||
acknowledgeButton.Click += AcknowledgeButton_Click;
|
||||
contentPanel.Controls.Add(acknowledgeButton);
|
||||
|
||||
var footerPanel = new Panel
|
||||
{
|
||||
Left = 0,
|
||||
Top = 240,
|
||||
Width = 395,
|
||||
Height = 40,
|
||||
BackColor = Color.White,
|
||||
};
|
||||
contentPanel.Controls.Add(footerPanel);
|
||||
|
||||
var footerBorder = new Panel
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
Width = 395,
|
||||
Height = 1,
|
||||
BackColor = Color.FromArgb(238, 238, 238),
|
||||
};
|
||||
footerPanel.Controls.Add(footerBorder);
|
||||
|
||||
countdownLabel = new Label
|
||||
{
|
||||
Text = "Manual acknowledgment required",
|
||||
Left = 0,
|
||||
Top = 12,
|
||||
Width = 395,
|
||||
Height = 20,
|
||||
TextAlign = ContentAlignment.MiddleCenter,
|
||||
Font = CreateFont("Segoe UI", 8.25f),
|
||||
ForeColor = Color.FromArgb(153, 153, 153),
|
||||
};
|
||||
footerPanel.Controls.Add(countdownLabel);
|
||||
}
|
||||
|
||||
private Color ParseTypeColor(string? hexColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hexColor))
|
||||
return Color.Gray;
|
||||
|
||||
var hex = hexColor.TrimStart('#');
|
||||
if (hex.Length != 6)
|
||||
return Color.Gray;
|
||||
|
||||
try
|
||||
{
|
||||
var r = Convert.ToByte(hex.Substring(0, 2), 16);
|
||||
var g = Convert.ToByte(hex.Substring(2, 2), 16);
|
||||
var b = Convert.ToByte(hex.Substring(4, 2), 16);
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Color.Gray;
|
||||
}
|
||||
}
|
||||
|
||||
private void AcknowledgeButton_Click(object? sender, EventArgs e)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CloseButton_Click(object? sender, EventArgs e)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
countdownTimer?.Dispose();
|
||||
statusCheckTimer?.Stop();
|
||||
statusCheckTimer?.Dispose();
|
||||
statusCheckCancellation.Cancel();
|
||||
statusCheckCancellation.Dispose();
|
||||
foreach (var font in fonts)
|
||||
{
|
||||
font.Dispose();
|
||||
}
|
||||
fonts.Clear();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
197
agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs
Normal file
197
agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Polls the server for fleet-wide alerts every 60 seconds and displays new
|
||||
// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in
|
||||
// FleetAlertStore and posted back to the server so they do not reappear in
|
||||
// future polls. Runs for the tray's lifetime on a dedicated background thread.
|
||||
internal sealed class FleetAlertWorker : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly FleetAlertClient client;
|
||||
private readonly FleetAlertStore store;
|
||||
private readonly string hostname;
|
||||
private readonly int assetId;
|
||||
private readonly ILogger logger;
|
||||
private readonly SynchronizationContext? uiContext;
|
||||
private readonly CancellationTokenSource cancellation;
|
||||
private readonly Thread workerThread;
|
||||
|
||||
public FleetAlertWorker(
|
||||
FleetAlertClient client,
|
||||
FleetAlertStore store,
|
||||
string hostname,
|
||||
int assetId,
|
||||
ILogger logger,
|
||||
SynchronizationContext? uiContext = null)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname));
|
||||
this.assetId = assetId;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.uiContext = uiContext ?? SynchronizationContext.Current;
|
||||
cancellation = new CancellationTokenSource();
|
||||
|
||||
workerThread = new Thread(Run)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "ShopDB fleet alert poller",
|
||||
};
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Fleet alert worker starting; first poll in {Delay}, then every {Interval}.",
|
||||
StartupDelay, PollInterval);
|
||||
workerThread.Start();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var token = cancellation.Token;
|
||||
try
|
||||
{
|
||||
Thread.Sleep(StartupDelay);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
PollAndDisplayAlerts(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Thread.Sleep(PollInterval);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PollAndDisplayAlerts(CancellationToken token)
|
||||
{
|
||||
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult();
|
||||
if (alerts is null || alerts.Count == 0)
|
||||
{
|
||||
logger.LogDebug("No active fleet alerts.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newAlerts = alerts
|
||||
.Where(alert => !store.IsAcknowledged(alert.NotificationId))
|
||||
.ToList();
|
||||
|
||||
if (newAlerts.Count == 0)
|
||||
{
|
||||
logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count);
|
||||
|
||||
foreach (var alert in newAlerts)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
ShowAlertOnUIThread(alert, token);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token)
|
||||
{
|
||||
var shown = new ManualResetEventSlim(false);
|
||||
Exception? formException = null;
|
||||
|
||||
var showAction = new SendOrPostCallback(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var form = new FleetAlertForm(alert, client, hostname);
|
||||
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
|
||||
var result = form.ShowDialog();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
formException = exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
shown.Set();
|
||||
}
|
||||
});
|
||||
|
||||
if (uiContext is not null)
|
||||
uiContext.Post(showAction, null);
|
||||
else
|
||||
showAction(null);
|
||||
|
||||
shown.Wait(token);
|
||||
|
||||
if (formException is not null)
|
||||
{
|
||||
logger.LogWarning(formException,
|
||||
"Could not display fleet alert {NotificationId}.", alert.NotificationId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId);
|
||||
|
||||
store.MarkAcknowledged(alert.NotificationId);
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token);
|
||||
logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not post acknowledgment for fleet alert {NotificationId}; already marked locally.",
|
||||
alert.NotificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation.Cancel();
|
||||
|
||||
if (workerThread.IsAlive)
|
||||
{
|
||||
if (!workerThread.Join(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
logger.LogWarning("Fleet alert worker thread did not exit within timeout.");
|
||||
}
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,22 @@
|
||||
copies of the exit-code handling. A tray that cannot open the channel
|
||||
logs and keeps its timer, so the failure mode is "refresh is not
|
||||
immediate", not "session data stops working". Requires agent 1.11.0. -->
|
||||
<Version>1.4.0</Version>
|
||||
<!-- 1.6.1: fix crash when clicking acknowledge button. The countdown timer
|
||||
is now properly stopped before the form closes to prevent disposal issues. -->
|
||||
<!-- 1.6.2: disable auto-dismiss timer. Fleet alerts now require manual
|
||||
acknowledgment only. Footer shows "Manual acknowledgment required" instead
|
||||
of countdown timer. -->
|
||||
<!-- 1.6.3: auto-close when notification becomes inactive. Fleet alert forms now
|
||||
check every 30 seconds if the notification is still active on the server and
|
||||
automatically close if it has ended or been deactivated. -->
|
||||
<!-- 1.6.4: fix UI thread deadlock. Acknowledgment and status checks now run async
|
||||
properly without blocking the UI thread. Acknowledgment is marked locally first,
|
||||
then posted to server in background to prevent lockups. -->
|
||||
<!-- 1.6.5: fix resource leaks and disposal issues. GraphicsPath properly disposed,
|
||||
Font objects tracked and disposed, worker thread joined before client disposal,
|
||||
Screen.PrimaryScreen null dereference prevented, status check cancellation token
|
||||
propagated to prevent orphaned HTTP requests. -->
|
||||
<Version>1.6.5</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -82,6 +82,14 @@ internal sealed class TrayContext : ApplicationContext
|
||||
// guard and launch two probes at the same spool file.
|
||||
private readonly SynchronizationContext? uiContext;
|
||||
|
||||
// Fleet alerts are pulled from the server and displayed to the operator as
|
||||
// toast notifications in the top-right corner. The worker polls every 60
|
||||
// seconds, filters out already-acknowledged alerts via the store, and posts
|
||||
// acknowledgements back to the server when the operator dismisses the form.
|
||||
private readonly FleetAlertClient? fleetAlertClient;
|
||||
private readonly FleetAlertStore? fleetAlertStore;
|
||||
private readonly FleetAlertWorker? fleetAlertWorker;
|
||||
|
||||
// The tray logs into the operator's own profile
|
||||
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
|
||||
// service's ProgramData directory. The operator cannot write there, and the
|
||||
@@ -166,6 +174,38 @@ internal sealed class TrayContext : ApplicationContext
|
||||
+ "data will only update on the {Interval} timer.",
|
||||
UserContextInterval);
|
||||
}
|
||||
|
||||
// Fleet alert worker. Best-effort: if the config cannot be loaded or the
|
||||
// worker fails to start, the tray still runs and the other features work.
|
||||
// The operator just does not receive fleet-wide alerts. Loading the config
|
||||
// requires reading %ProgramData%\ShopDB\config.json, which is written by
|
||||
// the installer and owned by SYSTEM but readable by the operator. Loading
|
||||
// the asset ID requires reading the spool file written by the service.
|
||||
try
|
||||
{
|
||||
var config = AgentConfigProvider.Load(logger: logger);
|
||||
var assetId = ResolveAssetId();
|
||||
var hostname = Environment.MachineName;
|
||||
|
||||
fleetAlertClient = new FleetAlertClient(config,
|
||||
logProvider.CreateLogger(nameof(FleetAlertClient)));
|
||||
fleetAlertStore = new FleetAlertStore(
|
||||
logProvider.CreateLogger(nameof(FleetAlertStore)));
|
||||
fleetAlertWorker = new FleetAlertWorker(
|
||||
fleetAlertClient,
|
||||
fleetAlertStore,
|
||||
hostname,
|
||||
assetId,
|
||||
logProvider.CreateLogger(nameof(FleetAlertWorker)),
|
||||
uiContext);
|
||||
fleetAlertWorker.Start();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not start the fleet alert worker; fleet-wide alerts will not "
|
||||
+ "be displayed. Other tray features are unaffected.");
|
||||
}
|
||||
}
|
||||
|
||||
// Waits for service refresh requests for the life of the tray.
|
||||
@@ -642,6 +682,36 @@ internal sealed class TrayContext : ApplicationContext
|
||||
ExitThread();
|
||||
}
|
||||
|
||||
// Resolves this machine's asset ID from the telemetry state file written by
|
||||
// the service. Used to identify which asset acknowledged a fleet alert. If
|
||||
// the state file is missing or unreadable, returns 0 as a signal that fleet
|
||||
// alerts should be disabled (the asset is not enrolled).
|
||||
private int ResolveAssetId()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stateFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "state.json");
|
||||
if (!File.Exists(stateFile))
|
||||
return 0;
|
||||
|
||||
var json = File.ReadAllText(stateFile);
|
||||
var state = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
|
||||
if (state is not null && state.TryGetValue("assetId", out var value))
|
||||
{
|
||||
if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
return element.GetInt32();
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not resolve asset ID from state file.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
@@ -655,6 +725,9 @@ internal sealed class TrayContext : ApplicationContext
|
||||
refreshCancellation.Cancel();
|
||||
refreshCancellation.Dispose();
|
||||
|
||||
fleetAlertWorker?.Dispose();
|
||||
fleetAlertClient?.Dispose();
|
||||
|
||||
trayIcon.Dispose();
|
||||
brandIcon.Dispose();
|
||||
}
|
||||
|
||||
@@ -186,23 +186,32 @@
|
||||
member of that group, so Start-ScheduledTask returned access denied and
|
||||
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
|
||||
is the side that listens, so an older tray simply never answers. -->
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. No tray change - tray
|
||||
stays 1.4.0. -->
|
||||
<Version>1.14.0</Version>
|
||||
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
|
||||
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
|
||||
code changes. Tray is at 1.6.5 from wj. -->
|
||||
<Version>1.14.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
218
darkwing-fixes.patch
Normal file
218
darkwing-fixes.patch
Normal file
@@ -0,0 +1,218 @@
|
||||
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/shopdb/__init__.py
|
||||
+++ b/shopdb/__init__.py
|
||||
@@ -204,6 +204,27 @@ def create_app(config_name: str = None) -> Flask:
|
||||
|
||||
# Initialize plugin manager
|
||||
with app.app_context():
|
||||
+ # On first run (no users exist), log the setup token so the operator can
|
||||
+ # create the first admin. The token gates POST /api/setup/create-admin to
|
||||
+ # prevent a network race during the install window (API starts serving
|
||||
+ # before the admin exists in every documented deploy path).
|
||||
+ from shopdb.core.models import User
|
||||
+ try:
|
||||
+ if User.query.count() == 0:
|
||||
+ token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
|
||||
+ if token:
|
||||
+ app.logger.warning('=' * 60)
|
||||
+ app.logger.warning('FIRST RUN: No users exist.')
|
||||
+ app.logger.warning('Setup token: %s', token)
|
||||
+ app.logger.warning('Create admin: POST /api/setup/create-admin')
|
||||
+ app.logger.warning(' with header: X-Setup-Token: <token>')
|
||||
+ app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin')
|
||||
+ app.logger.warning('=' * 60)
|
||||
+ else:
|
||||
+ app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.')
|
||||
+ app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin')
|
||||
+ except Exception:
|
||||
+ pass # DB not ready yet, skip
|
||||
plugin_manager.init_app(app, db)
|
||||
|
||||
# Register core blueprints
|
||||
diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py
|
||||
index 2345678..bcdefgh 100644
|
||||
--- a/shopdb/cli/__init__.py
|
||||
+++ b/shopdb/cli/__init__.py
|
||||
@@ -196,6 +196,30 @@ def drop_all():
|
||||
@click.group('seed')
|
||||
def seed_cli():
|
||||
"""Database seeding commands."""
|
||||
pass
|
||||
|
||||
|
||||
+@seed_cli.command('show-setup-token')
|
||||
+@with_appcontext
|
||||
+def show_setup_token():
|
||||
+ """Display the setup token for first-admin creation.
|
||||
+
|
||||
+ The token gates POST /api/setup/create-admin so a network race cannot claim
|
||||
+ the instance during the install window. Printed at first startup; retrieve
|
||||
+ it here if the logs rotated or you lost the terminal.
|
||||
+ """
|
||||
+ import os
|
||||
+ from flask import current_app
|
||||
+ from shopdb.core.models import User
|
||||
+
|
||||
+ if User.query.count() > 0:
|
||||
+ click.echo(click.style("Setup already complete (users exist).", fg='green'))
|
||||
+ return
|
||||
+
|
||||
+ token = current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
|
||||
+ if token:
|
||||
+ click.echo(click.style("Setup token: ", fg='cyan') + token)
|
||||
+ click.echo("Use: curl -X POST .../api/setup/create-admin -H 'X-Setup-Token: <token>' ...")
|
||||
+ else:
|
||||
+ click.echo(click.style("No SETUP_TOKEN configured.", fg='yellow'))
|
||||
+ click.echo("Use 'flask seed admin' from inside the container (no token needed).")
|
||||
+
|
||||
+
|
||||
@seed_cli.command('admin')
|
||||
diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py
|
||||
index 3456789..cdefghi 100644
|
||||
--- a/shopdb/core/api/setup.py
|
||||
+++ b/shopdb/core/api/setup.py
|
||||
@@ -5,6 +5,10 @@ per-plugin maps. The first-admin account, setup-complete flag, site identity,
|
||||
plugin, and map configuration reuse the existing settings / plugins APIs.
|
||||
"""
|
||||
|
||||
+import hmac
|
||||
+import ipaddress
|
||||
+import os
|
||||
+
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
@@ -29,6 +33,45 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
+# Minimum length for the bootstrap administrator password. 12 characters is the
|
||||
+# shortest length that still resists offline cracking of a single leaked hash.
|
||||
+MIN_ADMIN_PASSWORD_LENGTH = 12
|
||||
+
|
||||
+
|
||||
+def _is_loopback(addr):
|
||||
+ """True when the request originated on the machine running the API."""
|
||||
+ if not addr:
|
||||
+ return False
|
||||
+ try:
|
||||
+ return ipaddress.ip_address(addr.split('%')[0]).is_loopback
|
||||
+ except ValueError:
|
||||
+ return False
|
||||
+
|
||||
+
|
||||
+def _bootstrap_allowed():
|
||||
+ """Proof of operator control for first-admin creation.
|
||||
+
|
||||
+ An empty users table is an ordering condition, not authorization: the API
|
||||
+ serves requests before the first account exists on every fresh deploy, so a
|
||||
+ remote caller could otherwise claim the instance during the install window.
|
||||
+ Require either the one-time setup token (SETUP_TOKEN in config or the
|
||||
+ environment, presented as the X-Setup-Token header) or a request coming from
|
||||
+ loopback, i.e. the host the operator is installing on.
|
||||
+ """
|
||||
+ token = str(current_app.config.get('SETUP_TOKEN')
|
||||
+ or os.environ.get('SETUP_TOKEN') or '').strip()
|
||||
+ if token:
|
||||
+ supplied = (request.headers.get('X-Setup-Token') or '').strip()
|
||||
+ return bool(supplied) and hmac.compare_digest(supplied, token)
|
||||
+ return _is_loopback(request.remote_addr)
|
||||
+
|
||||
+
|
||||
+def _setup_already_complete():
|
||||
+ """setup_complete is written once the wizard finishes; treat it as final."""
|
||||
+ row = Setting.query.filter_by(key='setup_complete').first()
|
||||
+ return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes')
|
||||
+
|
||||
+
|
||||
@setup_bp.route('/needs-admin', methods=['GET'])
|
||||
def needs_admin():
|
||||
"""True when no users exist yet - the app's very first run. Public so the
|
||||
@@ -38,17 +81,29 @@ def needs_admin():
|
||||
|
||||
@setup_bp.route('/create-admin', methods=['POST'])
|
||||
def create_admin():
|
||||
- """Create the first admin. Only works while there are zero users, so it
|
||||
- can't be abused after setup. No auth (there is no one to auth as yet)."""
|
||||
- if User.query.count() > 0:
|
||||
+ """Create the first admin. Only works while there are zero users, setup has
|
||||
+ not been marked complete, and the caller proves operator control (setup
|
||||
+ token or a loopback request)."""
|
||||
+ if User.query.count() > 0 or _setup_already_complete():
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'An account already exists; sign in instead.', http_code=403)
|
||||
+ if not _bootstrap_allowed():
|
||||
+ return error_response(
|
||||
+ ErrorCodes.FORBIDDEN,
|
||||
+ 'First-admin creation requires the setup token (X-Setup-Token) '
|
||||
+ 'or a request from the server itself.',
|
||||
+ http_code=403)
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
email = (data.get('email') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
if not (username and email and password):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'username, email and password are required')
|
||||
+ if len(password) < MIN_ADMIN_PASSWORD_LENGTH:
|
||||
+ return error_response(
|
||||
+ ErrorCodes.VALIDATION_ERROR,
|
||||
+ f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters')
|
||||
|
||||
admin_role = Role.query.filter_by(rolename='admin').first()
|
||||
if not admin_role:
|
||||
@@ -61,4 +116,6 @@ def create_admin():
|
||||
user.roles.append(admin_role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
+ current_app.logger.warning('First administrator %r created from %s',
|
||||
+ username, request.remote_addr)
|
||||
return success_response({'username': username}, message='Admin created', http_code=201)
|
||||
diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py
|
||||
index 4567890..defghij 100644
|
||||
--- a/plugins/knowledgebase/api/routes.py
|
||||
+++ b/plugins/knowledgebase/api/routes.py
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Knowledge Base API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
-from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
intarg,
|
||||
@@ -180,19 +179,27 @@ def get_article(link_id: int):
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
||||
-@jwt_required(optional=True)
|
||||
+@require_permission('kb.view')
|
||||
def track_click(link_id: int):
|
||||
- """Increment click counter and return the URL to redirect to."""
|
||||
- article = db.session.get(KnowledgeBase, link_id)
|
||||
+ """Increment the click counter for an article the caller is allowed to read.
|
||||
|
||||
- if not article or not article.isactive:
|
||||
+ Gated on kb.view like every other read of this table: an anonymous caller
|
||||
+ walking link ids must not be able to probe which articles exist, drive the
|
||||
+ counters that order the list, or spend a DB transaction per request.
|
||||
+
|
||||
+ Restricted to the same visible set as the list/detail reads so an article
|
||||
+ about a retired application is not reachable here either.
|
||||
+
|
||||
+ The linkurl is deliberately not echoed back: whoever is permitted to click
|
||||
+ already received it in the list or detail response the click came from.
|
||||
+ """
|
||||
+ article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first()
|
||||
+
|
||||
+ if not article:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404)
|
||||
|
||||
article.increment_clicks()
|
||||
db.session.commit()
|
||||
|
||||
- return success_response({
|
||||
- 'linkurl': article.linkurl,
|
||||
- 'clicks': article.clicks
|
||||
- })
|
||||
+ return success_response({'clicks': article.clicks})
|
||||
15
findings.sarif
Normal file
15
findings.sarif
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
"version": "2.1.0",
|
||||
"runs": [
|
||||
{
|
||||
"tool": {
|
||||
"driver": {
|
||||
"name": "Darkwing",
|
||||
"rules": []
|
||||
}
|
||||
},
|
||||
"results": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,8 @@ from ..models import (
|
||||
)
|
||||
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK
|
||||
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
|
||||
from shopdb.utils.mailer import send_alert
|
||||
from markupsafe import escape
|
||||
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
|
||||
@@ -2943,6 +2945,85 @@ def _purge_expired_operator_reports():
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _send_operator_report_alert(report, asset_id, hostname):
|
||||
"""Send email alert for new operator issue report to default alert recipients."""
|
||||
from flask import url_for, request
|
||||
from shopdb.utils.mailer import get_smtp_config, _normalize_recipients
|
||||
|
||||
current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s',
|
||||
report.operatorreportid, asset_id, hostname)
|
||||
|
||||
# Check if we have recipients before doing expensive work
|
||||
config = get_smtp_config()
|
||||
current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s',
|
||||
config.get('enabled'), config.get('host'), config.get('alert_recipients'))
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
current_app.logger.warning('Skipping operator report alert: no alert recipients configured')
|
||||
return
|
||||
current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients)
|
||||
|
||||
asset = Asset.query.get(asset_id)
|
||||
if not asset:
|
||||
current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id)
|
||||
return
|
||||
|
||||
# Generate absolute URLs
|
||||
try:
|
||||
device_url = url_for('assets.get_asset', asset_id=asset_id, _external=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Failed to generate device URL: %s', e)
|
||||
# Build absolute URL manually using request context
|
||||
scheme = request.scheme if request else 'https'
|
||||
host = request.host if request else 'localhost'
|
||||
device_url = f'{scheme}://{host}/shopdb/assets/{asset_id}'
|
||||
|
||||
reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown'
|
||||
|
||||
screenshot_info = ''
|
||||
if report.imagefilename:
|
||||
try:
|
||||
screenshot_url = url_for('endpoints.serve_operator_report_image',
|
||||
operatorreportid=report.operatorreportid,
|
||||
_external=True)
|
||||
screenshot_info = f'<p><strong>Screenshot:</strong> <a href="{escape(screenshot_url)}">View Screenshot</a></p>'
|
||||
except Exception as e:
|
||||
current_app.logger.warning('Failed to generate screenshot URL: %s', e)
|
||||
|
||||
# HTML-escape all user-controlled values to prevent XSS
|
||||
safe_hostname = escape(hostname)
|
||||
safe_name = escape(asset.name or hostname)
|
||||
safe_assetnumber = escape(asset.assetnumber or 'N/A')
|
||||
safe_reason = escape(report.reason)
|
||||
safe_details = escape(report.details or 'No additional details provided.')
|
||||
|
||||
# Build HTML body (don't use render_email to avoid subject duplication)
|
||||
html_body = f'''
|
||||
<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;margin:0 auto;color:#222;">
|
||||
<h2 style="color:#1a1a1a;margin:0 0 12px;">Operator Issue Report</h2>
|
||||
<div style="padding: 16px; background: #f9f9f9; border-left: 4px solid #d93f3f; margin-bottom: 16px;">
|
||||
<p style="margin: 0 0 8px; font-weight: bold; color: #d93f3f;">New Issue Report from Shop Floor</p>
|
||||
<p style="margin: 0;"><strong>Device:</strong> <a href="{escape(device_url)}">{safe_name}</a> ({safe_hostname})</p>
|
||||
<p style="margin: 0;"><strong>Asset Number:</strong> <a href="{escape(device_url)}">{safe_assetnumber}</a></p>
|
||||
<p style="margin: 0;"><strong>Reason:</strong> {safe_reason}</p>
|
||||
<p style="margin: 0;"><strong>Reported:</strong> {escape(reported_at)}</p>
|
||||
</div>
|
||||
<p><strong>Details:</strong></p>
|
||||
<p style="white-space: pre-wrap;">{safe_details}</p>
|
||||
{screenshot_info}
|
||||
<p><a href="{escape(device_url)}" style="display: inline-block; padding: 10px 16px; background: #1a73e8; color: white; text-decoration: none; border-radius: 4px;">View Device in ShopDB</a></p>
|
||||
<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">
|
||||
<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
subject = f'Operator Issue Report: {safe_name}'
|
||||
|
||||
current_app.logger.info('Sending operator report alert for %s (report %d)',
|
||||
hostname, report.operatorreportid)
|
||||
send_alert(subject, html_body)
|
||||
|
||||
|
||||
@endpoints_bp.route('/operatorreports', methods=['POST'])
|
||||
def upload_operator_report():
|
||||
"""Agent uploads one operator issue report (collector-token auth).
|
||||
@@ -3039,6 +3120,14 @@ def upload_operator_report():
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
# Send email alert to default alert recipients
|
||||
current_app.logger.info('Operator report %d saved, attempting to send email alert', report.operatorreportid)
|
||||
try:
|
||||
_send_operator_report_alert(report, asset_id, hostname)
|
||||
current_app.logger.info('Email alert function completed for report %d', report.operatorreportid)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Failed to send operator report alert email for report %d: %s', report.operatorreportid, e, exc_info=True)
|
||||
|
||||
return success_response(report.to_dict(),
|
||||
message='Operator report received', http_code=201)
|
||||
|
||||
@@ -3085,3 +3174,148 @@ def serve_operator_report_image(operatorreportid: int):
|
||||
return send_upload(_operator_report_dir(), report.imagefilename)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fleet alerts (agent-facing, collector-token auth)
|
||||
# =============================================================================
|
||||
|
||||
_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8'
|
||||
|
||||
@endpoints_bp.route('/alerts', methods=['GET'])
|
||||
def get_fleet_alerts():
|
||||
"""Fleet alerts for one endpoint by hostname (collector-token auth).
|
||||
|
||||
Called by the endpoint agent to fetch active, unacknowledged fleet alerts
|
||||
for its hostname. Returns alerts with notification type info (typename and
|
||||
typecolor from database) formatted for display in the agent tray.
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if asset_id is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Unknown hostname: {hostname!r}', http_code=404)
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement
|
||||
|
||||
now = _naive_utcnow()
|
||||
|
||||
# Query for active fleet alerts not acknowledged by this asset
|
||||
query = (db.session.query(Notification, NotificationType)
|
||||
.join(NotificationType,
|
||||
NotificationType.notificationtypeid == Notification.notificationtypeid)
|
||||
.outerjoin(NotificationAcknowledgement,
|
||||
db.and_(
|
||||
NotificationAcknowledgement.notificationid == Notification.notificationid,
|
||||
NotificationAcknowledgement.assetid == asset_id))
|
||||
.filter(Notification.alertfleet.is_(True),
|
||||
Notification.isactive.is_(True),
|
||||
db.or_(
|
||||
Notification.endtime.is_(None),
|
||||
Notification.endtime > now),
|
||||
NotificationAcknowledgement.notificationacknowledgementid.is_(None)))
|
||||
|
||||
results = query.all()
|
||||
|
||||
alerts = []
|
||||
for notification, ntype in results:
|
||||
title = notification.notification[:100] if notification.notification else ''
|
||||
if '\n' in title:
|
||||
title = title.split('\n')[0][:100] or '(no title)'
|
||||
|
||||
alerts.append({
|
||||
'notificationId': notification.notificationid,
|
||||
'type': ntype.typename,
|
||||
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
|
||||
'title': title,
|
||||
'message': notification.notification or '',
|
||||
'timeout': notification.alerttimeout,
|
||||
})
|
||||
|
||||
return success_response(alerts)
|
||||
|
||||
|
||||
@endpoints_bp.route('/alerts/<int:notification_id>/acknowledge', methods=['POST'])
|
||||
def acknowledge_fleet_alert(notification_id: int):
|
||||
"""Agent acknowledges a fleet alert (collector-token auth).
|
||||
|
||||
The agent posts the hostname plus assetid when dismissing a fleet alert from
|
||||
the tray. Records acknowledgement so this alert will not reappear in future polls.
|
||||
Idempotent - repeated acknowledgements succeed.
|
||||
"""
|
||||
error = _collector_auth_or_error()
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
assetid = payload.get('assetid')
|
||||
|
||||
if not hostname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required')
|
||||
|
||||
if assetid is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'assetid is required')
|
||||
|
||||
try:
|
||||
assetid = int(assetid)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'assetid must be an integer')
|
||||
|
||||
from plugins.notifications.models import Notification, NotificationAcknowledgement
|
||||
|
||||
notification = db.session.get(Notification, notification_id)
|
||||
if notification is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Notification {notification_id} not found',
|
||||
http_code=404)
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if asset is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Asset {assetid} not found',
|
||||
http_code=404)
|
||||
|
||||
# Validate hostname matches assetid to prevent cross-machine acknowledgements
|
||||
resolved_asset = _resolve_asset_for_hostname(hostname)
|
||||
if resolved_asset is None or resolved_asset.assetid != assetid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname does not match provided assetid')
|
||||
|
||||
# Check if already acknowledged (idempotent)
|
||||
existing = (NotificationAcknowledgement.query
|
||||
.filter_by(notificationid=notification_id, assetid=assetid)
|
||||
.first())
|
||||
if existing:
|
||||
return success_response({'acknowledged': True},
|
||||
message='Alert already acknowledged')
|
||||
|
||||
# Record acknowledgement
|
||||
ack = NotificationAcknowledgement(
|
||||
notificationid=notification_id,
|
||||
assetid=assetid
|
||||
)
|
||||
db.session.add(ack)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}')
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR,
|
||||
'Failed to record acknowledgement',
|
||||
http_code=500)
|
||||
|
||||
return success_response({'acknowledged': True},
|
||||
message='Alert acknowledged', http_code=201)
|
||||
|
||||
|
||||
|
||||
@@ -455,7 +455,10 @@ def create_notification():
|
||||
isactive=True,
|
||||
isshopfloor=data.get('isshopfloor', False),
|
||||
employeesso=data.get('employeesso'),
|
||||
employeename=data.get('employeename')
|
||||
employeename=data.get('employeename'),
|
||||
alertfleet=data.get('alertfleet', False),
|
||||
alertscope=data.get('alertscope'),
|
||||
alerttimeout=data.get('alerttimeout', 0)
|
||||
)
|
||||
|
||||
db.session.add(n)
|
||||
@@ -504,6 +507,12 @@ def update_notification(notification_id: int):
|
||||
n.employeesso = data['employeesso']
|
||||
if 'employeename' in data:
|
||||
n.employeename = data['employeename']
|
||||
if 'alertfleet' in data:
|
||||
n.alertfleet = data['alertfleet']
|
||||
if 'alertscope' in data:
|
||||
n.alertscope = data['alertscope']
|
||||
if 'alerttimeout' in data:
|
||||
n.alerttimeout = data['alerttimeout']
|
||||
|
||||
# Parse and update dates
|
||||
if 'starttime' in data or 'startdate' in data:
|
||||
|
||||
@@ -217,6 +217,46 @@
|
||||
</label>
|
||||
<small class="form-hint">Display on TV dashboard</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.alertfleet"
|
||||
:disabled="isDetail || !form.isactive"
|
||||
/>
|
||||
Alert Fleet
|
||||
</label>
|
||||
<small class="form-hint">Push this notification to shop floor PCs</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.alertfleet" class="fleet-alert-options">
|
||||
<div class="form-group">
|
||||
<label for="alertscope">Alert Scope (JSON)</label>
|
||||
<textarea
|
||||
id="alertscope"
|
||||
v-model="alertScopeText"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
:disabled="isDetail"
|
||||
placeholder='{"assettype": "computer", "location": "Building A"}'
|
||||
></textarea>
|
||||
<small class="form-hint">Optional: Filter alerts by asset type, location, or business unit (JSON format)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alerttimeout">Auto-dismiss Timeout (minutes)</label>
|
||||
<input
|
||||
id="alerttimeout"
|
||||
v-model.number="form.alerttimeout"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
:disabled="isDetail"
|
||||
/>
|
||||
<small class="form-hint">Minutes until auto-dismiss (0 = manual acknowledgment only)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
@@ -282,13 +322,42 @@ const form = ref({
|
||||
// loads its own stored value (see loadNotification), so this never flips a
|
||||
// setting somebody deliberately turned off.
|
||||
isshopfloor: true,
|
||||
employeesso: ''
|
||||
employeesso: '',
|
||||
alertfleet: false,
|
||||
alertscope: null,
|
||||
alerttimeout: 0
|
||||
})
|
||||
|
||||
// Site timezone (settings key site_timezone) so form times show/enter in the
|
||||
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
|
||||
const siteTimezone = ref(DEFAULT_TZ)
|
||||
|
||||
// Alert scope as text for editing (converts to/from JSON)
|
||||
const alertScopeText = ref('')
|
||||
|
||||
watch(() => form.value.alertscope, (newVal) => {
|
||||
if (newVal && typeof newVal === 'object') {
|
||||
alertScopeText.value = JSON.stringify(newVal, null, 2)
|
||||
} else if (newVal && typeof newVal === 'string') {
|
||||
alertScopeText.value = newVal
|
||||
} else {
|
||||
alertScopeText.value = ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(alertScopeText, (newVal) => {
|
||||
if (!newVal.trim()) {
|
||||
form.value.alertscope = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
form.value.alertscope = JSON.parse(newVal)
|
||||
} catch (e) {
|
||||
// Keep the text as-is until it's valid JSON
|
||||
form.value.alertscope = newVal
|
||||
}
|
||||
})
|
||||
|
||||
function selectedTypeName() {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
return selectedType?.typename?.toLowerCase() || ''
|
||||
@@ -349,7 +418,10 @@ onMounted(async () => {
|
||||
endtime: formatDateForInput(n.endtime),
|
||||
isactive: n.isactive !== false,
|
||||
isshopfloor: n.isshopfloor || false,
|
||||
employeesso: n.employeesso || ''
|
||||
employeesso: n.employeesso || '',
|
||||
alertfleet: n.alertfleet || false,
|
||||
alertscope: n.alertscope || null,
|
||||
alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0
|
||||
}
|
||||
|
||||
// Parse existing employee data: SSOs are comma-joined and names are
|
||||
@@ -527,7 +599,10 @@ async function saveNotification() {
|
||||
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
|
||||
isactive: form.value.isactive,
|
||||
isshopfloor: form.value.isshopfloor,
|
||||
employeesso: form.value.employeesso || null
|
||||
employeesso: form.value.employeesso || null,
|
||||
alertfleet: form.value.alertfleet || false,
|
||||
alertscope: form.value.alertscope || null,
|
||||
alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0
|
||||
}
|
||||
|
||||
// For employee-photo types, also send employeename
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Add fleet alert support to notifications plugin.
|
||||
|
||||
Adds alertfleet, alertscope, and alerttimeout columns to the notifications table
|
||||
to support fleet-wide alert broadcasting. Creates notificationacknowledgements
|
||||
table to track which assets have acknowledged fleet alerts.
|
||||
|
||||
Fleet alerts can be scoped to specific asset types, locations, or business units
|
||||
via the alertscope TEXT field (JSON string). alerttimeout=0 means no auto-dismiss (manual only).
|
||||
|
||||
Idempotent: column and table creation are skipped when they already exist.
|
||||
|
||||
Revision ID: notifications0006fleetalerts
|
||||
Revises: notifications0005boardorder
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'notifications0006fleetalerts'
|
||||
down_revision = 'notifications0005boardorder'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_names(insp, table):
|
||||
return {c['name'] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing_tables = set(insp.get_table_names())
|
||||
|
||||
# Add alertfleet, alertscope, alerttimeout to notifications table
|
||||
if 'notifications' in existing_tables:
|
||||
cols = _column_names(insp, 'notifications')
|
||||
|
||||
if 'alertfleet' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alertfleet', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()))
|
||||
|
||||
if 'alertscope' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alertscope', sa.Text, nullable=True))
|
||||
|
||||
if 'alerttimeout' not in cols:
|
||||
op.add_column('notifications',
|
||||
sa.Column('alerttimeout', sa.Integer(), nullable=False,
|
||||
server_default='0'))
|
||||
|
||||
# Create notificationacknowledgements table
|
||||
if 'notificationacknowledgements' not in existing_tables:
|
||||
op.create_table(
|
||||
'notificationacknowledgements',
|
||||
sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False),
|
||||
sa.Column('notificationid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
sa.Column('acknowledgedat', sa.DateTime(), nullable=False,
|
||||
server_default=sa.text('NOW()')),
|
||||
sa.PrimaryKeyConstraint('notificationacknowledgementid'),
|
||||
sa.ForeignKeyConstraint(['notificationid'],
|
||||
['notifications.notificationid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('notificationid', 'assetid',
|
||||
name='uq_notification_asset')
|
||||
)
|
||||
op.create_index('idx_notificationack_notification',
|
||||
'notificationacknowledgements',
|
||||
['notificationid'])
|
||||
op.create_index('idx_notificationack_asset',
|
||||
'notificationacknowledgements',
|
||||
['assetid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
existing_tables = set(insp.get_table_names())
|
||||
|
||||
# Drop notificationacknowledgements table
|
||||
if 'notificationacknowledgements' in existing_tables:
|
||||
op.drop_index('idx_notificationack_asset',
|
||||
table_name='notificationacknowledgements')
|
||||
op.drop_index('idx_notificationack_notification',
|
||||
table_name='notificationacknowledgements')
|
||||
op.drop_table('notificationacknowledgements')
|
||||
|
||||
# Drop alert columns from notifications table
|
||||
if 'notifications' in existing_tables:
|
||||
cols = _column_names(insp, 'notifications')
|
||||
|
||||
if 'alerttimeout' in cols:
|
||||
op.drop_column('notifications', 'alerttimeout')
|
||||
|
||||
if 'alertscope' in cols:
|
||||
op.drop_column('notifications', 'alertscope')
|
||||
|
||||
if 'alertfleet' in cols:
|
||||
op.drop_column('notifications', 'alertfleet')
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notifications plugin models."""
|
||||
|
||||
from .notification import Notification, NotificationType
|
||||
from .acknowledgement import NotificationAcknowledgement
|
||||
|
||||
__all__ = ['Notification', 'NotificationType']
|
||||
__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement']
|
||||
|
||||
52
plugins/notifications/models/acknowledgement.py
Normal file
52
plugins/notifications/models/acknowledgement.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Notification acknowledgement tracking for fleet alerts."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from shopdb.api import db
|
||||
|
||||
|
||||
class NotificationAcknowledgement(db.Model):
|
||||
"""
|
||||
Tracks which assets have acknowledged fleet alerts.
|
||||
Matches existing notificationacknowledgements table.
|
||||
"""
|
||||
__tablename__ = 'notificationacknowledgements'
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'),
|
||||
)
|
||||
|
||||
notificationacknowledgementid = db.Column(db.Integer, primary_key=True)
|
||||
notificationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('notifications.notificationid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
acknowledgedat = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
)
|
||||
|
||||
# Relationships
|
||||
notification = db.relationship('Notification', backref='acknowledgements')
|
||||
asset = db.relationship('Asset', backref='notification_acknowledgements')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NotificationAcknowledgement {self.notificationacknowledgementid}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary following ShopDB patterns."""
|
||||
return {
|
||||
'notificationacknowledgementid': self.notificationacknowledgementid,
|
||||
'notificationid': self.notificationid,
|
||||
'assetid': self.assetid,
|
||||
'acknowledgedat': self.acknowledgedat.replace(
|
||||
tzinfo=timezone.utc
|
||||
).isoformat() if self.acknowledgedat else None
|
||||
}
|
||||
@@ -146,6 +146,9 @@ class Notification(db.Model):
|
||||
# ~11 people are listed.
|
||||
employeesso = db.Column(db.Text, nullable=True)
|
||||
employeename = db.Column(db.Text, nullable=True)
|
||||
alertfleet = db.Column(db.Boolean, default=False)
|
||||
alertscope = db.Column(db.JSON, nullable=True)
|
||||
alerttimeout = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
notificationtype = db.relationship('NotificationType', backref='notifications')
|
||||
@@ -194,7 +197,10 @@ class Notification(db.Model):
|
||||
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'iscurrent': self.is_current
|
||||
'iscurrent': self.is_current,
|
||||
'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False,
|
||||
'alertscope': self.alertscope,
|
||||
'alerttimeout': self.alerttimeout
|
||||
}
|
||||
|
||||
# Add type info
|
||||
|
||||
Reference in New Issue
Block a user