- create_ticket_api.php: remove the wrong CREATE TABLE stub that broke a fresh DB; generate collision-safe ticket_ids so a genuine id collision isn't misreported as a duplicate and a hw alert dropped; stop leaking raw DB errors; correct a reopen comment that falsely claimed refreshed sensor data - manage_recurring.php: fix next-run so create/edit no longer skips the current period (monthly day-of-month this month, daily today if time not passed, correct ISO weekday, month-length clamp); only recompute on schedule changes to avoid double-fire - export_tickets.php, audit_log.php: neutralize CSV formula injection - revoke_api_key.php, generate_api_key.php: correct HTTP status codes and stop the catch clobbering specific 4xx codes - health.php: stop leaking PHP version / extension names / paths to unauthenticated callers - watch_ticket.php: define $data before use - manage_templates/recurring/custom_fields: add audit logging for CRUD; add recurring_ticket + custom_field to the audit entity whitelist Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
492 lines
18 KiB
PHP
492 lines
18 KiB
PHP
<?php
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0);
|
|
|
|
// Load environment variables with error check
|
|
$envFile = __DIR__ . '/.env';
|
|
if (!file_exists($envFile)) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Configuration file not found'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
|
if (!$envVars) {
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Invalid configuration file'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Strip quotes from values if present (parse_ini_file may include them)
|
|
foreach ($envVars as $key => $value) {
|
|
if (is_string($value)) {
|
|
if (
|
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
|
) {
|
|
$envVars[$key] = substr($value, 1, -1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Database connection with detailed error handling
|
|
$conn = new mysqli(
|
|
$envVars['DB_HOST'],
|
|
$envVars['DB_USER'],
|
|
$envVars['DB_PASS'],
|
|
$envVars['DB_NAME']
|
|
);
|
|
|
|
if ($conn->connect_error) {
|
|
error_log('create_ticket_api: DB connection failed: ' . $conn->connect_error);
|
|
http_response_code(500);
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Internal server error'
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Load application config so UrlHelper can resolve APP_DOMAIN
|
|
require_once __DIR__ . '/config/config.php';
|
|
|
|
// Authenticate via API key
|
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
|
|
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
|
|
|
try {
|
|
$systemUser = $apiKeyAuth->authenticate();
|
|
} catch (Exception $e) {
|
|
// Authentication failed - ApiKeyAuth already sent the response
|
|
exit;
|
|
}
|
|
|
|
$userId = $systemUser['user_id'];
|
|
|
|
// Parse input regardless of content-type header
|
|
$rawInput = file_get_contents('php://input');
|
|
$data = json_decode($rawInput, true);
|
|
|
|
// Validate required fields before any processing
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
// Try URL-encoded fallback
|
|
if (empty($data['title'])) {
|
|
parse_str($rawInput, $urlData);
|
|
if (!empty($urlData['title'])) {
|
|
$data = $urlData;
|
|
}
|
|
}
|
|
if (!is_array($data) || empty($data['title'])) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
// Generate hash from stable components
|
|
function generateTicketHash($data)
|
|
{
|
|
$title = (string)($data['title'] ?? '');
|
|
|
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
|
// for backwards compatibility with older hwmonDaemon versions.
|
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
|
? (string)$data['serial']
|
|
: null;
|
|
|
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
|
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
|
|
|
|
// Extract first bracketed tag as hostname/source
|
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
|
$hostname = $hostMatches[1] ?? '';
|
|
|
|
// Detect issue category and optional sub-type
|
|
$issueCategory = '';
|
|
$issueSubtype = '';
|
|
$isClusterWide = false;
|
|
|
|
if (stripos($title, 'SMART issues') !== false) {
|
|
$issueCategory = 'smart';
|
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
|
$issueCategory = 'zfs';
|
|
// Extract pool name so each pool gets its own ticket
|
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
|
$issueSubtype = 'pool_state_' . $poolName;
|
|
} elseif (stripos($title, 'usage') !== false) {
|
|
$issueSubtype = 'pool_usage_' . $poolName;
|
|
} elseif (stripos($title, 'errors') !== false) {
|
|
$issueSubtype = 'pool_errors_' . $poolName;
|
|
} else {
|
|
$issueSubtype = 'pool_' . $poolName;
|
|
}
|
|
}
|
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
|
$issueCategory = 'storage';
|
|
// Include the LXC container ID so each container gets its own ticket
|
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
|
}
|
|
} elseif (stripos($title, 'memory') !== false) {
|
|
$issueCategory = 'memory';
|
|
} elseif (stripos($title, 'cpu') !== false) {
|
|
$issueCategory = 'cpu';
|
|
} elseif (stripos($title, 'network') !== false) {
|
|
$issueCategory = 'network';
|
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
|
$issueCategory = 'ceph';
|
|
if (
|
|
stripos($title, '[cluster-wide]') !== false ||
|
|
stripos($title, 'HEALTH_ERR') !== false ||
|
|
stripos($title, 'HEALTH_WARN') !== false ||
|
|
stripos($title, 'cluster usage') !== false
|
|
) {
|
|
$isClusterWide = true;
|
|
}
|
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
|
$issueSubtype = 'bluestore_slow';
|
|
} elseif (stripos($title, 'clock skew') !== false) {
|
|
$issueSubtype = 'clock_skew';
|
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
|
$issueSubtype = 'usage';
|
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
|
} else {
|
|
$issueSubtype = 'osd_down';
|
|
}
|
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
|
$issueSubtype = 'health_err';
|
|
}
|
|
}
|
|
|
|
// Include source type so automated tickets never collide with manual ones
|
|
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
|
|
|
|
// Build stable components
|
|
$stableComponents = [
|
|
'source_type' => $sourceType,
|
|
'issue_category' => $issueCategory,
|
|
'issue_subtype' => $issueSubtype,
|
|
'environment_tags' => (function () use ($title) {
|
|
// Extract each [bracketed] tag, then keep the known environment ones.
|
|
// (explode('][') leaves brackets stuck to the first/last tag, so e.g.
|
|
// "[production] ..." never matched and the env tag was dropped from the
|
|
// dedup hash — letting prod and staging issues collide onto one ticket.)
|
|
preg_match_all('/\[([^\]]+)\]/', $title, $m);
|
|
return array_values(array_filter(
|
|
$m[1],
|
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'], true)
|
|
));
|
|
})(),
|
|
];
|
|
|
|
// Manual tickets should be unique by title (so different software installs don't collide)
|
|
if ($sourceType === 'manual') {
|
|
$stableComponents['title'] = $title;
|
|
}
|
|
|
|
// Include hostname for node-specific issues
|
|
if (!$isClusterWide) {
|
|
$stableComponents['hostname'] = $hostname;
|
|
}
|
|
|
|
// Include drive identifier for drive-specific tickets.
|
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
|
// device path for tickets created before serial was added to the payload.
|
|
if ($isDriveTicket) {
|
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
|
}
|
|
|
|
sort($stableComponents['environment_tags']);
|
|
|
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
// Shared ticket data
|
|
$title = (string)($data['title'] ?? '');
|
|
$description = (string)($data['description'] ?? '');
|
|
$status = (string)($data['status'] ?? 'Open');
|
|
$priority = $data['priority'] ?? '4';
|
|
$category = (string)($data['category'] ?? 'General');
|
|
$type = (string)($data['type'] ?? 'Issue');
|
|
|
|
// Validate externally-supplied status and priority. (category/type are free-form
|
|
// in this schema.) A non-numeric priority would otherwise cast to 0 and escalate
|
|
// the ticket below P1 on the dedup/update path.
|
|
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
|
if (!in_array($status, $validStatuses, true)) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid status']);
|
|
exit;
|
|
}
|
|
if (!is_numeric($priority) || (int)$priority < 1 || (int)$priority > 5) {
|
|
http_response_code(400);
|
|
echo json_encode(['success' => false, 'error' => 'Invalid priority (must be 1-5)']);
|
|
exit;
|
|
}
|
|
$priority = (int)$priority;
|
|
|
|
$ticketHash = generateTicketHash($data);
|
|
$auditLog = new AuditLogModel($conn);
|
|
|
|
// Look up any existing ticket with this hash (open OR closed)
|
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
|
$checkStmt->bind_param("s", $ticketHash);
|
|
$checkStmt->execute();
|
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
|
$checkStmt->close();
|
|
|
|
if ($existing) {
|
|
$existingId = $existing['ticket_id'];
|
|
$existingStatus = $existing['status'];
|
|
$existingTitle = $existing['title'];
|
|
$existingPriority = (int)$existing['priority'];
|
|
$newPriority = (int)$priority;
|
|
|
|
if ($existingStatus !== 'Closed') {
|
|
// Ticket is still active — update title, escalate priority, and refresh
|
|
// description with latest sensor data.
|
|
$changes = [];
|
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
|
$bindTypes = "i";
|
|
$bindVals = [$userId];
|
|
|
|
if ($title !== $existingTitle) {
|
|
$updateSql .= ", title = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $title;
|
|
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
|
}
|
|
|
|
if ($newPriority < $existingPriority) {
|
|
$updateSql .= ", priority = ?";
|
|
$bindTypes .= "i";
|
|
$bindVals[] = $newPriority;
|
|
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
|
}
|
|
|
|
// Always refresh the description so the ticket body shows current sensor data
|
|
if (!empty($description)) {
|
|
$updateSql .= ", description = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $description;
|
|
$changes['description_refreshed'] = true;
|
|
}
|
|
|
|
if (!empty($changes)) {
|
|
$updateSql .= " WHERE ticket_id = ?";
|
|
$bindTypes .= "s";
|
|
$bindVals[] = $existingId;
|
|
|
|
$updStmt = $conn->prepare($updateSql);
|
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
|
$updStmt->execute();
|
|
$updStmt->close();
|
|
|
|
// Only post a comment on priority escalation — title and description updates
|
|
// are silent (title changes like rising counters would spam a comment every run).
|
|
// Keep it short: the full sensor data is refreshed in the ticket description,
|
|
// so the comment just records the bump + a brief reason (no ASCII dump).
|
|
if (isset($changes['priority'])) {
|
|
$pLabels = [1 => 'P1 (Critical)', 2 => 'P2 (High)', 3 => 'P3 (Medium)', 4 => 'P4 (Low)', 5 => 'P5 (Minimal)'];
|
|
$fromP = (int)$changes['priority']['from'];
|
|
$toP = (int)$changes['priority']['to'];
|
|
$fromL = $pLabels[$fromP] ?? "P{$fromP}";
|
|
$toL = $pLabels[$toP] ?? "P{$toP}";
|
|
$commentText = "**hwmonDaemon raised priority {$fromL} → {$toL}.**\n\n"
|
|
. "The latest monitoring scan reported a more severe condition for this issue, "
|
|
. "so it now needs faster attention. Current sensor data is in the ticket description above.";
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
}
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
|
array_diff_key($changes, ['description_refreshed' => true]),
|
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
|
));
|
|
|
|
// Only notify on priority escalation — title-only updates (e.g. rising
|
|
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
|
if (isset($changes['priority'])) {
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $changes['priority']['to'],
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $existingStatus,
|
|
], 'automated');
|
|
}
|
|
}
|
|
|
|
$conn->close();
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
|
'changes' => $changes,
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// Ticket was closed — reopen it and add a recurrence comment
|
|
$reopenStmt = $conn->prepare(
|
|
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
|
);
|
|
$reopenStmt->bind_param("is", $userId, $existingId);
|
|
$reopenStmt->execute();
|
|
$reopenStmt->close();
|
|
|
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
|
"hwmonDaemon detected this condition again. The ticket description reflects the "
|
|
. "original report; see this comment's timestamp for when the issue recurred.";
|
|
$commentStmt = $conn->prepare(
|
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
|
);
|
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
|
$commentStmt->execute();
|
|
$commentStmt->close();
|
|
|
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
|
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($existingId, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => 'Open',
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $existingId,
|
|
'message' => 'Existing closed ticket reopened',
|
|
'action' => 'reopened',
|
|
]);
|
|
exit;
|
|
}
|
|
|
|
// No existing ticket — create a new one.
|
|
// Generate a collision-safe unique ticket_id with a pre-check + retry loop (same
|
|
// approach as TicketModel::createTicket) so a ticket_id clash cannot happen. That
|
|
// way a 1062 on INSERT below can only be the unique_hash (dedup) key racing, and
|
|
// is correctly reported as a duplicate rather than a dropped hardware alert.
|
|
$ticket_id = null;
|
|
$maxAttempts = 50;
|
|
$attempts = 0;
|
|
do {
|
|
try {
|
|
$candidateId = sprintf('%09d', random_int(100000000, 999999999));
|
|
} catch (Exception $e) {
|
|
$candidateId = sprintf('%09d', mt_rand(100000000, 999999999));
|
|
}
|
|
|
|
$idCheckStmt = $conn->prepare("SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1");
|
|
$idCheckStmt->bind_param("s", $candidateId);
|
|
$idCheckStmt->execute();
|
|
$idExists = $idCheckStmt->get_result()->num_rows > 0;
|
|
$idCheckStmt->close();
|
|
|
|
if (!$idExists) {
|
|
$ticket_id = $candidateId;
|
|
}
|
|
$attempts++;
|
|
} while ($ticket_id === null && $attempts < $maxAttempts);
|
|
|
|
if ($ticket_id === null) {
|
|
error_log('create_ticket_api: failed to generate a unique ticket_id after ' . $maxAttempts . ' attempts');
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
exit;
|
|
}
|
|
|
|
$insertStmt = $conn->prepare(
|
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
$insertStmt->bind_param(
|
|
"ssssssssi",
|
|
$ticket_id,
|
|
$title,
|
|
$description,
|
|
$status,
|
|
$priority,
|
|
$category,
|
|
$type,
|
|
$ticketHash,
|
|
$userId
|
|
);
|
|
|
|
try {
|
|
$inserted = $insertStmt->execute();
|
|
} catch (mysqli_sql_exception $e) {
|
|
$insertStmt->close();
|
|
if ($e->getCode() === 1062) {
|
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
|
} else {
|
|
error_log('create_ticket_api: insert failed: ' . $e->getMessage());
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|
|
exit;
|
|
}
|
|
$insertStmt->close();
|
|
|
|
if ($inserted) {
|
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
]);
|
|
|
|
$conn->close();
|
|
|
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
|
'title' => $title,
|
|
'priority' => $priority,
|
|
'category' => $category,
|
|
'type' => $type,
|
|
'status' => $status,
|
|
], 'automated');
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'ticket_id' => $ticket_id,
|
|
'message' => 'Ticket created successfully',
|
|
]);
|
|
} else {
|
|
error_log('create_ticket_api: ticket insert reported failure: ' . $conn->error);
|
|
http_response_code(500);
|
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
|
}
|