Nginx Realtime Proxy Monitor (Version ล่าสุด)

ระบบตรวจจับสถานะผู้ชมแบบเรียลไทม์ผ่านระดับ Network Socket (ss) และ Nginx Access Log

Feature จุดเด่นและกลไกการทำงาน

1 เตรียมสภาพแวดล้อมและ Node.js Packages

สร้างโฟลเดอร์สำหรับรัน Dashboard และติดตั้งโมดูลที่จำเป็น:

sudo mkdir -p /var/www/html/mistserver-monitor
cd /var/www/html/mistserver-monitor
sudo npm init -y
sudo npm install express cors

2 โค้ดระบบหลังบ้าน server.js (Port 4000)

สร้างไฟล์ /var/www/html/mistserver-monitor/server.js ด้วยโค้ดชุดล่าสุด:

cat << 'EOF' > /var/www/html/mistserver-monitor/server.js
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');

const app = express();
app.use(cors());
app.use(express.json());

const PORT = 4000;
const NGINX_LOG_PATH = '/var/log/nginx/access.log';
const LOG_FILE_PATH = path.join(__dirname, 'stream_logs.json');

let liveViewersMap = new Map();
let ipToStreamNameMaster = new Map();

function scanPersistentConnections() {
    exec("ss -tnp state established '( sport = :80 or dport = :80 )'", function(error, stdout, stderr) {
        let output = stdout;
        if (error) {
            exec("netstat -anp | grep :80 | grep ESTABLISHED", function(err, out) {
                if (!err) processActiveIps(out);
            });
            return;
        }
        processActiveIps(output);
    });
}

function processActiveIps(output) {
    if (!output) return;

    const lines = output.split('\n');
    const currentlyConnectedIps = new Set();

    lines.forEach(function(line) {
        const ipMatches = line.match(/([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}):[0-9]+/g);
        if (ipMatches) {
            ipMatches.forEach(function(matchedStr) {
                const ip = matchedStr.split(':')[0];
                if (ip !== '127.0.0.1' && ip !== '172.30.100.9') {
                    currentlyConnectedIps.add(ip);
                }
            });
        }
    });

    const now = Date.now();

    currentlyConnectedIps.forEach(function(ip) {
        let streamName = ipToStreamNameMaster.get(ip);
        
        if (!streamName) {
            for (let [key, val] of liveViewersMap.entries()) {
                if (key.startsWith(ip + "_")) {
                    streamName = val.stream;
                    break;
                }
            }
        }
        
        if (!streamName) streamName = 'pptv'; 

        // เคลียร์ช่องเก่าของ IP นี้ออกทันทีเมื่อเปลี่ยนไปดูช่องอื่น
        for (let [k, v] of liveViewersMap.entries()) {
            if (v.host === ip && v.stream !== streamName) {
                liveViewersMap.delete(k);
            }
        }

        const key = ip + "_" + streamName;
        const exist = liveViewersMap.get(key);

        if (!exist) {
            saveToHistoryJson(ip, streamName, 'MPEG-TS (.ts)');
        }

        liveViewersMap.set(key, {
            host: ip,
            stream: streamName,
            protocol: 'MPEG-TS Stream',
            last_seen: now
        });
    });

    for (let [key, value] of liveViewersMap.entries()) {
        if (!currentlyConnectedIps.has(value.host) && (now - value.last_seen > 45000)) {
            liveViewersMap.delete(key);
        }
    }
}

function extractStreamInfo(line) {
    if (!line || (line.indexOf('.ts') === -1 && line.indexOf('.m3u8') === -1)) return null;
    try {
        const ipMatch = line.match(/^([0-9a-fA-F.:]+)/);
        const ip = ipMatch ? ipMatch[1] : null;
        if (!ip || ip === '127.0.0.1' || ip === '172.30.100.9') return null;

        let streamName = 'Unknown';
        const urlMatch = line.match(/\"(GET|POST|HEAD)\s+\/([^?\s]+)/);
        if (urlMatch && urlMatch[2]) {
            const parts = urlMatch[2].split(/[\/.]/);
            streamName = (parts[0] === 'hls' && parts[1]) ? parts[1] : parts[0];
        }

        if (streamName === 'Unknown' || streamName === '') {
            if (line.toLowerCase().indexOf('pptv') !== -1) streamName = 'pptv';
            else if (line.toLowerCase().indexOf('nbt') !== -1) streamName = 'nbt';
        }
        return { ip, streamName };
    } catch(e) { return null; }
}

setInterval(scanPersistentConnections, 3000);

if (fs.existsSync(NGINX_LOG_PATH)) {
    let currentFileSize = fs.statSync(NGINX_LOG_PATH).size;
    fs.watch(path.dirname(NGINX_LOG_PATH), function(eventType, filename) {
        if (filename !== path.basename(NGINX_LOG_PATH)) return;
        try {
            const stats = fs.statSync(NGINX_LOG_PATH);
            if (stats.size <= currentFileSize) { currentFileSize = stats.size; return; }
            const logStream = fs.createReadStream(NGINX_LOG_PATH, { start: currentFileSize, end: stats.size });
            let buffer = '';
            logStream.on('data', function(chunk) {
                buffer += chunk.toString();
                const lines = buffer.split('\n');
                buffer = lines.pop();
                lines.forEach(function(line) {
                    const parsed = extractStreamInfo(line);
                    if (parsed && parsed.streamName !== 'Unknown') {
                        ipToStreamNameMaster.set(parsed.ip, parsed.streamName);

                        for (let [k, v] of liveViewersMap.entries()) {
                            if (v.host === parsed.ip && v.stream !== parsed.streamName) {
                                liveViewersMap.delete(k);
                            }
                        }
                        
                        const key = parsed.ip + "_" + parsed.streamName;
                        liveViewersMap.set(key, {
                            host: parsed.ip,
                            stream: parsed.streamName,
                            protocol: line.indexOf('.m3u8') !== -1 ? 'HLS (.m3u8)' : 'MPEG-TS (.ts)',
                            last_seen: Date.now()
                        });
                    }
                });
            });
            logStream.on('end', function() { currentFileSize = stats.size; });
        } catch(e){}
    });
}

function saveToHistoryJson(ip, streamName, protocol) {
    let logs = [];
    if (fs.existsSync(LOG_FILE_PATH)) {
        try { logs = JSON.parse(fs.readFileSync(LOG_FILE_PATH, 'utf8')); } catch (e) { logs = []; }
    }
    const newLog = {
        id: Date.now() + Math.random().toString(36).substr(2, 5),
        ip: ip,
        stream_name: streamName,
        protocol: protocol,
        connected_at: new Date().toISOString()
    };
    logs.unshift(newLog);
    if (logs.length > 300) logs = logs.slice(0, 300);
    fs.writeFileSync(LOG_FILE_PATH, JSON.stringify(logs, null, 2), 'utf8');
}

setInterval(() => { ipToStreamNameMaster.clear(); }, 43200000);

app.get('/', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });
app.get('/api/live-viewers', function(req, res) {
    const activeList = Array.from(liveViewersMap.values()).map(function(v) {
        return { host: v.host, stream: v.stream, protocol: v.protocol, conntime: "ONLINE" };
    });
    res.json(activeList);
});
app.get('/api/logs', function(req, res) {
    if (!fs.existsSync(LOG_FILE_PATH)) return res.json([]);
    try { res.json(JSON.parse(fs.readFileSync(LOG_FILE_PATH, 'utf8'))); } catch (e) { res.json([]); }
});

app.listen(PORT, function() {
    console.log(`🚀 Ultimate Persistent .TS Monitor running on port ${PORT}`);
});
EOF

3 จัดการ Service ด้วย PM2

สั่งให้โปรเซสทำงานเบื้องหลังด้วยสิทธิ์ root เพื่อให้สามารถรันคำสั่งตรวจสอบ Socket (ss) ได้อย่างสมบูรณ์:

# ติดตั้ง PM2 (หากยังไม่มี)
sudo npm install -g pm2

# สั่งเริ่มการทำงาน Service
sudo pm2 start /var/www/html/mistserver-monitor/server.js --name mist-monitor

# ตั้งค่าให้ออโต้รันตอนเปิดเครื่อง
sudo pm2 save
sudo pm2 startup

4 ทดสอบและเรียกดูข้อมูล

ตรวจสอบสถานะโปรเซสผ่าน PM2:

sudo pm2 status mist-monitor
sudo pm2 logs mist-monitor

ทดสอบดึงข้อมูล JSON ของผู้ชมสดผ่าน API:

curl -s http://127.0.0.1:4000/api/live-viewers | jq .