Implement Monitor API v1.1 changes

BREAKING CHANGES:
- IP endpoint changed: ip-pool -> servers&for_monitor=1
- BurnVote: removed 'reason' field, 'monitor_id' extracted from token
- Evidence format updated with checked_at ISO timestamp

NEW FEATURES:
- Heartbeat endpoint (POST gateway-sync&action=heartbeat)
- Rate limiting with retry logic (429 handling)
- 60-second heartbeat timer in main process

DEPRECATED:
- register() method - token now created via admin panel

Files changed:
- src/shared/types.ts - Updated PanelIpInfo, BurnVote, added HeartbeatStats
- src/main/services/PanelService.ts - Full API v1.1 implementation
- src/main/index.ts - Heartbeat timer, checks counter
- src/main/preload.ts - panelHeartbeat IPC
- src/renderer/types/electron.d.ts - Updated types
This commit is contained in:
alper 2025-12-01 01:30:00 +03:00
parent c6dbff3fdb
commit 367a1c428e
7 changed files with 321 additions and 56 deletions

File diff suppressed because one or more lines are too long

View File

@ -21,7 +21,7 @@ import { NotificationService } from './services/NotificationService'
import { PanelService } from './services/PanelService'
import { M3u8Parser } from './services/M3u8Parser'
import { IPC_CHANNELS } from '../shared/types'
import type { AppSettings, Channel, ErrorLogEntry } from '../shared/types'
import type { AppSettings, Channel, ErrorLogEntry, HeartbeatStats } from '../shared/types'
// App quitting flag
let isAppQuitting = false
@ -34,6 +34,10 @@ let notificationService: NotificationService
let panelService: PanelService
let m3u8Parser: M3u8Parser
// Heartbeat timer
let heartbeatInterval: NodeJS.Timeout | null = null
let checksLastMinute = 0
// Window and tray
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
@ -166,6 +170,9 @@ function initializeServices() {
streamChecker = new StreamChecker(
settings.monitoring,
(result) => {
// Increment checks counter for heartbeat stats
incrementChecksCounter()
// Send result to renderer
mainWindow?.webContents.send(IPC_CHANNELS.MONITORING_UPDATE, result)
@ -201,6 +208,67 @@ function initializeServices() {
console.log('[App] Portable mode:', isPortableMode())
}
/**
* Start heartbeat timer - sends heartbeat every 60 seconds
* API v1.1: New heartbeat endpoint for monitor liveness
*/
function startHeartbeat(): void {
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
}
const settings = getSettings()
if (!settings.panel.enabled) {
console.log('[Heartbeat] Panel not enabled, skipping heartbeat')
return
}
// Send heartbeat every 60 seconds
heartbeatInterval = setInterval(async () => {
if (!panelService.isEnabled()) return
const channels = getChannels()
const monitoredChannels = channels.filter(c => c.isMonitored)
const stats: HeartbeatStats = {
active_checks: monitoredChannels.length,
checks_last_minute: checksLastMinute,
cpu_usage_percent: process.cpuUsage().user / 1000000, // Convert to percent
memory_usage_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024)
}
const result = await panelService.sendHeartbeat(stats)
if (result.success) {
console.log('[Heartbeat] Sent successfully')
} else {
console.warn('[Heartbeat] Failed:', result.message)
}
// Reset checks counter
checksLastMinute = 0
}, 60000) // 60 seconds
console.log('[Heartbeat] Timer started (60s interval)')
}
/**
* Stop heartbeat timer
*/
function stopHeartbeat(): void {
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
heartbeatInterval = null
console.log('[Heartbeat] Timer stopped')
}
}
/**
* Increment checks counter for heartbeat stats
*/
function incrementChecksCounter(): void {
checksLastMinute++
}
function setupIpcHandlers() {
// Window controls
ipcMain.on('window:minimize', () => mainWindow?.minimize())
@ -214,7 +282,7 @@ function setupIpcHandlers() {
ipcMain.on('window:close', () => {
// Çarpı butonuna basınca uygulamayı tamamen kapat
isAppQuitting = true
mainWindow?.close()
app.quit()
})
// IP Service
@ -296,6 +364,11 @@ function setupIpcHandlers() {
return await panelService.sendBurnVote(vote)
})
// Panel Heartbeat - API v1.1
ipcMain.handle(IPC_CHANNELS.PANEL_HEARTBEAT, async (_, stats: HeartbeatStats) => {
return await panelService.sendHeartbeat(stats)
})
// Settings
ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, async () => {
return getSettings()
@ -337,6 +410,9 @@ app.whenReady().then(() => {
createWindow()
createTray()
setupIpcHandlers()
// Start heartbeat timer - API v1.1
startHeartbeat()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
@ -353,5 +429,6 @@ app.on('window-all-closed', () => {
app.on('before-quit', () => {
isAppQuitting = true
stopHeartbeat()
streamChecker?.stop()
})

View File

@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import { IPC_CHANNELS } from '../shared/types'
import type { Channel, AppSettings, BurnVote, NotificationType } from '../shared/types'
import type { Channel, AppSettings, BurnVote, NotificationType, HeartbeatStats } from '../shared/types'
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('electron', {
@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld('electron', {
panelRegister: (config: unknown) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_REGISTER, config),
panelGetIps: () => ipcRenderer.invoke(IPC_CHANNELS.PANEL_GET_IPS),
panelSendVote: (vote: BurnVote) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_SEND_VOTE, vote),
panelHeartbeat: (stats: HeartbeatStats) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_HEARTBEAT, stats),
// Settings
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),

View File

@ -1,9 +1,28 @@
import axios, { AxiosInstance } from 'axios'
import type { PanelConfig, PanelIpPool, BurnVote } from '../../shared/types'
import axios, { AxiosInstance, AxiosError } from 'axios'
import type {
PanelConfig,
PanelIpPool,
BurnVote,
HeartbeatStats,
HeartbeatResponse,
PanelIpInfo
} from '../../shared/types'
// Rate limiting state
interface RateLimitState {
isLimited: boolean
retryAfter: number
lastLimitTime: number
}
export class PanelService {
private config: PanelConfig
private client: AxiosInstance | null = null
private rateLimitState: RateLimitState = {
isLimited: false,
retryAfter: 0,
lastLimitTime: 0
}
constructor(config: PanelConfig) {
this.config = config
@ -26,29 +45,50 @@ export class PanelService {
timeout: 10000,
headers: {
'Content-Type': 'application/json',
// API v1.1: Token is now 64-char hex from admin panel
...(this.config.authToken && {
'Authorization': `Bearer ${this.config.authToken}`
})
}
})
// Add response interceptor for rate limiting
this.client.interceptors.response.use(
response => response,
async (error: AxiosError) => {
if (error.response?.status === 429) {
const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10)
this.rateLimitState = {
isLimited: true,
retryAfter,
lastLimitTime: Date.now()
}
console.warn(`[PanelService] Rate limited. Retry after ${retryAfter}s`)
}
throw error
}
)
}
/**
* Register monitor with panel
* Based on MONITOR_API_GUIDE.md
* @deprecated API v1.1: Token is now created via admin panel
* This method is kept for backwards compatibility but will log a warning
*/
async register(): Promise<{ success: boolean; authToken?: string; error?: string }> {
console.warn('[PanelService] register() is deprecated in API v1.1. Token should be created via admin panel.')
if (!this.client) {
return { success: false, error: 'Panel not configured' }
}
try {
const os = await import('os')
const response = await this.client.post('/api.php?endpoint=servers', {
server_id: this.config.monitorId,
server_type: 'monitor',
hostname: require('os').hostname(),
hostname: os.hostname(),
ip_address: await this.getLocalIp(),
country_code: 'TR' // Could be detected dynamically
country_code: 'TR'
})
if (response.data.success) {
@ -66,104 +106,208 @@ export class PanelService {
/**
* Get IP pool from panel
* Based on MONITOR_API_GUIDE.md - Option A: REST API (Polling)
* API v1.1: Changed endpoint from ip-pool to servers&for_monitor=1
*/
async getIpPool(): Promise<PanelIpPool> {
if (!this.client || !this.config.enabled) {
return { active: [], honeypot: [] }
return { ips: [], timestamp: Date.now() }
}
// Check rate limit
if (this.isRateLimited()) {
console.warn('[PanelService] Rate limited, skipping getIpPool')
return { ips: [], timestamp: Date.now() }
}
try {
// API v1.1: New endpoint
const response = await this.client.get('/api.php', {
params: {
endpoint: 'ip-pool',
for_monitor: true
endpoint: 'servers',
for_monitor: '1'
}
})
if (response.data.success) {
return response.data.ips || { active: [], honeypot: [] }
// API v1.1: New response format
if (response.data.ips) {
return {
ips: response.data.ips as PanelIpInfo[],
timestamp: response.data.timestamp || Date.now()
}
}
console.error('Failed to get IP pool:', response.data.message)
return { active: [], honeypot: [] }
// Legacy format support
if (response.data.success && response.data.ips) {
const legacyIps = response.data.ips
return {
ips: [...(legacyIps.active || []), ...(legacyIps.honeypot || [])],
timestamp: response.data.timestamp || Date.now(),
active: legacyIps.active,
honeypot: legacyIps.honeypot
}
}
console.error('[PanelService] Failed to get IP pool:', response.data.message)
return { ips: [], timestamp: Date.now() }
} catch (error) {
console.error('Failed to fetch IP pool:', error)
return { active: [], honeypot: [] }
if ((error as AxiosError).response?.status !== 429) {
console.error('[PanelService] Failed to fetch IP pool:', error)
}
return { ips: [], timestamp: Date.now() }
}
}
/**
* Send burn vote
* Based on MONITOR_API_GUIDE.md - Section 2: Burn Bildirimi
* API v1.1: monitor_id removed from payload (extracted from Bearer token)
* API v1.1: reason field removed, evidence format changed
*/
async sendBurnVote(vote: BurnVote): Promise<{ success: boolean; error?: string }> {
async sendBurnVote(vote: BurnVote): Promise<{ success: boolean; error?: string; currentVotes?: object }> {
if (!this.client || !this.config.enabled) {
return { success: false, error: 'Panel not enabled' }
}
// Check rate limit
if (this.isRateLimited()) {
console.warn('[PanelService] Rate limited, queuing burn vote')
return { success: false, error: 'Rate limited, try again later' }
}
try {
// API v1.1: New payload format - no monitor_id, no reason
const response = await this.client.post('/api.php?endpoint=burn-vote', {
ip_address: vote.ip_address,
vote: vote.vote,
reason: vote.reason,
evidence: {
...vote.evidence,
monitor_id: this.config.monitorId,
timestamp: Math.floor(Date.now() / 1000)
}
evidence: vote.evidence
})
if (response.data.success) {
console.log('Burn vote submitted:', {
console.log('[PanelService] Burn vote submitted:', {
ip: vote.ip_address,
vote: vote.vote,
current_votes: response.data.current_votes
})
return { success: true }
return {
success: true,
currentVotes: response.data.current_votes
}
}
return { success: false, error: response.data.message }
} catch (error) {
const axiosError = error as AxiosError
// Handle 403 - wrong server type
if (axiosError.response?.status === 403) {
const errorData = axiosError.response.data as { message?: string }
console.error('[PanelService] Forbidden:', errorData.message)
return { success: false, error: errorData.message || 'Forbidden: Server type not allowed' }
}
// Retry on rate limit
if (axiosError.response?.status === 429) {
const retryAfter = parseInt(axiosError.response.headers['retry-after'] || '60', 10)
console.warn(`[PanelService] Rate limited on burn vote. Waiting ${retryAfter}s...`)
// Wait and retry once
await this.sleep(retryAfter * 1000)
return this.sendBurnVote(vote)
}
return { success: false, error: (error as Error).message }
}
}
/**
* Report IP health status
* Send heartbeat to panel
* API v1.1: New endpoint for monitor liveness
*/
async sendHeartbeat(stats: HeartbeatStats): Promise<HeartbeatResponse> {
if (!this.client || !this.config.enabled) {
return { success: false, message: 'Panel not enabled' }
}
// Check rate limit
if (this.isRateLimited()) {
return { success: false, message: 'Rate limited' }
}
try {
const response = await this.client.post(
'/api.php?endpoint=gateway-sync&action=heartbeat',
{ data: stats }
)
if (response.data.success) {
console.log('[PanelService] Heartbeat sent successfully')
return { success: true, message: response.data.message || 'Heartbeat received' }
}
return { success: false, message: response.data.message || 'Heartbeat failed' }
} catch (error) {
console.error('[PanelService] Heartbeat failed:', error)
return { success: false, message: (error as Error).message }
}
}
/**
* Report IP health status - updated for API v1.1
*/
async reportHealth(
ipAddress: string,
isWorking: boolean,
checkResult: {
checkType: string
port?: number
checkType: 'http_health' | 'tcp_connect' | 'ssl_verify' | 'dns_lookup'
responseCode?: number
latencyMs?: number
error?: string
}
): Promise<void> {
// API v1.1: New evidence format with checked_at ISO timestamp
const vote: BurnVote = {
ip_address: ipAddress,
vote: isWorking ? 'ok' : 'burn',
reason: isWorking ? 'IP working normally' : checkResult.error || 'Check failed',
evidence: {
check_type: checkResult.checkType,
port: checkResult.port,
response_time_ms: checkResult.latencyMs,
error_message: checkResult.error,
timestamp: Math.floor(Date.now() / 1000),
monitor_id: this.config.monitorId
response_code: checkResult.responseCode || 0,
latency_ms: checkResult.latencyMs || 0,
error_message: checkResult.error || null,
checked_at: new Date().toISOString()
}
}
await this.sendBurnVote(vote)
}
/**
* Check if currently rate limited
*/
private isRateLimited(): boolean {
if (!this.rateLimitState.isLimited) {
return false
}
const elapsed = (Date.now() - this.rateLimitState.lastLimitTime) / 1000
if (elapsed >= this.rateLimitState.retryAfter) {
this.rateLimitState.isLimited = false
return false
}
return true
}
/**
* Sleep utility for retry logic
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Get local IP address
*/
private async getLocalIp(): Promise<string> {
const os = require('os')
const os = await import('os')
const interfaces = os.networkInterfaces()
for (const name of Object.keys(interfaces)) {
@ -183,5 +327,21 @@ export class PanelService {
isEnabled(): boolean {
return this.config.enabled && !!this.config.apiUrl && !!this.config.authToken
}
}
/**
* Get rate limit status for UI display
*/
getRateLimitStatus(): { isLimited: boolean; secondsRemaining: number } {
if (!this.rateLimitState.isLimited) {
return { isLimited: false, secondsRemaining: 0 }
}
const elapsed = (Date.now() - this.rateLimitState.lastLimitTime) / 1000
const remaining = Math.max(0, this.rateLimitState.retryAfter - elapsed)
return {
isLimited: remaining > 0,
secondsRemaining: Math.ceil(remaining)
}
}
}

View File

@ -30,6 +30,7 @@ export interface ElectronAPI {
panelRegister: (config: import('@shared/types').PanelConfig) => Promise<{ success: boolean; authToken?: string }>
panelGetIps: () => Promise<import('@shared/types').PanelIpPool>
panelSendVote: (vote: import('@shared/types').BurnVote) => Promise<{ success: boolean }>
panelHeartbeat: (stats: import('@shared/types').HeartbeatStats) => Promise<import('@shared/types').HeartbeatResponse>
// Settings
getSettings: () => Promise<import('@shared/types').AppSettings>

View File

@ -71,37 +71,62 @@ export interface NotificationSettings {
notifyOnConnReset: boolean;
}
// Panel API Types (from MONITOR_API_GUIDE)
// Panel API Types (from MONITOR_API_GUIDE v1.1)
export interface PanelConfig {
apiUrl: string;
authToken: string;
authToken: string; // Now 64-char hex token from admin panel
monitorId: string;
enabled: boolean;
}
// Updated for API v1.1 - new response format from servers endpoint
export interface PanelIpInfo {
ip_address: string;
server_id: string;
check_priority: 'high' | 'normal' | 'low';
server_type: 'publisher' | 'monitor' | 'gateway';
status: 'active' | 'inactive' | 'burned';
active_users?: number;
// Legacy field for backwards compatibility
check_priority?: 'high' | 'normal' | 'low';
}
// Updated response format for API v1.1
export interface PanelIpPool {
active: PanelIpInfo[];
honeypot: PanelIpInfo[];
ips: PanelIpInfo[];
timestamp: number;
// Legacy fields for backwards compatibility
active?: PanelIpInfo[];
honeypot?: PanelIpInfo[];
}
// Updated for API v1.1 - monitor_id removed (extracted from Bearer token)
export interface BurnVote {
ip_address: string;
vote: 'burn' | 'ok' | 'abstain';
reason: string;
evidence: {
check_type: string;
port?: number;
response_time_ms?: number;
error_message?: string;
timestamp: number;
monitor_id: string;
};
evidence: BurnVoteEvidence;
}
// New evidence format for API v1.1
export interface BurnVoteEvidence {
check_type: 'http_health' | 'tcp_connect' | 'ssl_verify' | 'dns_lookup';
response_code: number;
latency_ms: number;
error_message: string | null;
checked_at: string; // ISO 8601 format
}
// New for API v1.1 - Heartbeat stats
export interface HeartbeatStats {
active_checks: number;
checks_last_minute: number;
cpu_usage_percent?: number;
memory_usage_mb?: number;
}
// Heartbeat response
export interface HeartbeatResponse {
success: boolean;
message: string;
}
// App Settings
@ -147,9 +172,10 @@ export const IPC_CHANNELS = {
TEST_NOTIFICATION: 'notify:test',
// Panel API
PANEL_REGISTER: 'panel:register',
PANEL_REGISTER: 'panel:register', // Deprecated in v1.1
PANEL_GET_IPS: 'panel:get-ips',
PANEL_SEND_VOTE: 'panel:send-vote',
PANEL_HEARTBEAT: 'panel:heartbeat',
// Settings
GET_SETTINGS: 'settings:get',