posimai-root/posimai-dev/server.js

328 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// .env を手動ロードdotenv 不要)
try {
require('fs').readFileSync(require('path').join(__dirname, '.env'), 'utf8')
.split('\n').forEach(line => {
const eq = line.indexOf('=');
if (eq > 0) process.env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
});
} catch (_) {}
const express = require('express');
const { WebSocketServer } = require('ws');
const pty = require('node-pty');
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const app = express();
const PORT = process.env.PORT || 3333;
// セッションログ保存ディレクトリ
const SESSIONS_DIR = path.join(os.homedir(), 'posimai-dev-sessions');
if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
app.use(express.json());
app.use(express.static(path.join(__dirname)));
// /station → station.html エイリアス
app.get('/station', (req, res) => res.sendFile(path.join(__dirname, 'station.html')));
// /station-b → station-b.html エイリアス
app.get('/station-b', (req, res) => res.sendFile(path.join(__dirname, 'station-b.html')));
// /sessions → sessions.html エイリアス
app.get('/sessions', (req, res) => res.sendFile(path.join(__dirname, 'sessions.html')));
// セッション API 用ミドルウェアTailscale ネットワーク外からのアクセスを拒否)
function requireLocal(req, res, next) {
const ip = req.ip || req.connection.remoteAddress || '';
const allowed = ip === '::1' || ip === '127.0.0.1' || ip.startsWith('100.');
if (!allowed) return res.status(403).json({ error: 'forbidden' });
next();
}
// セッション一覧 API
app.get('/api/sessions', requireLocal, (req, res) => {
const files = fs.readdirSync(SESSIONS_DIR)
.filter((f) => f.endsWith('.log'))
.map((f) => {
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
return { id: f.replace('.log', ''), size: stat.size, mtime: stat.mtime };
})
.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
res.json(files);
});
// セッション内容 API
app.get('/api/sessions/:id', requireLocal, (req, res) => {
const file = path.join(SESSIONS_DIR, req.params.id + '.log');
if (!fs.existsSync(file)) return res.status(404).json({ error: 'not found' });
res.type('text/plain').send(fs.readFileSync(file, 'utf8'));
});
// ── ネットワーク I/O デルタ計算 ────────────────────────────────
let _netPrev = null, _netPrevTime = null;
function getNetDelta() {
try {
const raw = fs.readFileSync('/proc/net/dev', 'utf8');
const now = Date.now();
let rx = 0, tx = 0;
for (const line of raw.trim().split('\n').slice(2)) {
const parts = line.trim().split(/\s+/);
const iface = parts[0].replace(':', '');
if (iface === 'lo') continue;
rx += parseInt(parts[1]) || 0;
tx += parseInt(parts[9]) || 0;
}
let result = null;
if (_netPrev && _netPrevTime) {
const dt = (now - _netPrevTime) / 1000;
result = {
rx_kbps: Math.max(0, Math.round((rx - _netPrev.rx) / dt / 1024)),
tx_kbps: Math.max(0, Math.round((tx - _netPrev.tx) / dt / 1024)),
};
}
_netPrev = { rx, tx }; _netPrevTime = now;
return result;
} catch (_) { return null; }
}
// ── CPU 温度 (/sys/class/thermal/) ─────────────────────────────
function getCpuTemp() {
try {
const zones = fs.readdirSync('/sys/class/thermal/').filter(z => z.startsWith('thermal_zone'));
for (const zone of zones) {
try {
const type = fs.readFileSync(`/sys/class/thermal/${zone}/type`, 'utf8').trim();
if (['x86_pkg_temp','cpu-thermal','acpitz'].includes(type) || type.startsWith('cpu')) {
return Math.round(parseInt(fs.readFileSync(`/sys/class/thermal/${zone}/temp`, 'utf8')) / 1000);
}
} catch (_) {}
}
return Math.round(parseInt(fs.readFileSync('/sys/class/thermal/thermal_zone0/temp', 'utf8')) / 1000);
} catch (_) { return null; }
}
// ── ヘルス & メトリクス API (/api/health) ──────────────────────
// Atlas など外部から参照される。CORS ヘッダーを付与して Vercel 上の Atlas からも取得可能にする
function getCpuSample() {
return os.cpus().map((c) => {
const total = Object.values(c.times).reduce((a, b) => a + b, 0);
return { total, idle: c.times.idle };
});
}
app.get('/api/health', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const mem = os.freemem();
const total = os.totalmem();
// CPU: 100ms 間隔の2サンプルで実使用率を計算
const s1 = getCpuSample();
setTimeout(() => {
const s2 = getCpuSample();
const cpuPct = s1.reduce((sum, c1, i) => {
const c2 = s2[i];
const dIdle = c2.idle - c1.idle;
const dTotal = c2.total - c1.total;
return sum + (dTotal > 0 ? (1 - dIdle / dTotal) * 100 : 0);
}, 0) / s1.length;
// Disk usage
let disk = null;
try {
const dfOut = execSync('df -B1 / 2>/dev/null', { timeout: 2000 }).toString();
const parts = dfOut.trim().split('\n')[1].split(/\s+/);
const totalB = parseInt(parts[1]);
const usedB = parseInt(parts[2]);
disk = {
total_gb: Math.round(totalB / 1e9 * 10) / 10,
used_gb: Math.round(usedB / 1e9 * 10) / 10,
use_pct: Math.round(usedB / totalB * 100),
};
} catch (_) {}
// Load average (1 / 5 / 15 min) and CPU count
const loadAvg = os.loadavg();
const cpuCount = os.cpus().length;
res.json({
ok: true,
hostname: os.hostname(),
uptime_s: Math.floor(os.uptime()),
cpu_pct: Math.round(cpuPct),
cpu_count: cpuCount,
load_avg: loadAvg.map(l => Math.round(l * 100) / 100),
mem_used_mb: Math.round((total - mem) / 1024 / 1024),
mem_total_mb: Math.round(total / 1024 / 1024),
disk,
net: getNetDelta(),
cpu_temp_c: getCpuTemp(),
active_sessions: wss.clients ? wss.clients.size : 0,
node_version: process.version,
platform: os.platform(),
timestamp: new Date().toISOString(),
});
}, 100);
});
// ── Gitea 最新コミット (/api/gitea-commit) ─────────────────────
app.get('/api/gitea-commit', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const token = process.env.GITEA_TOKEN || '';
const headers = token ? { Authorization: `token ${token}` } : {};
const r = await fetch('http://100.76.7.3:3000/api/v1/repos/mai/posimai-root/commits?limit=1', {
headers, signal: AbortSignal.timeout(3000),
});
const data = await r.json();
const c = data[0];
res.json({
sha: c.sha.slice(0, 7),
message: c.commit.message.split('\n')[0].slice(0, 60),
author: c.commit.author.name,
date: c.commit.author.date,
});
} catch (e) { res.json({ error: e.message }); }
});
// ── Vercel 最新デプロイ (/api/vercel-deploys) ──────────────────
app.get('/api/vercel-deploys', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const token = process.env.VERCEL_TOKEN || '';
if (!token) return res.status(503).json({ error: 'no token' });
try {
const r = await fetch('https://api.vercel.com/v6/deployments?limit=1', {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(6000),
});
const data = await r.json();
const d = data.deployments?.[0];
if (!d) return res.json({ error: 'no deployments' });
res.json({
name: d.name,
state: d.state,
url: d.url,
created: d.created,
});
} catch (e) { res.status(502).json({ error: e.message }); }
});
// ── VPS health プロキシ (/api/vps-health) ──────────────────────
// ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ
// VPS_API_KEY を .env に設定すると詳細情報(メモリ・ディスク等)を取得できる
app.get('/api/vps-health', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const headers = {};
if (process.env.VPS_API_KEY) {
headers['Authorization'] = `Bearer ${process.env.VPS_API_KEY}`;
}
const r = await fetch('https://api.soar-enrich.com/api/health', {
headers,
signal: AbortSignal.timeout(6000),
});
const data = await r.json();
res.json({ ok: true, ...data });
} catch (e) {
res.status(502).json({ error: e.message });
}
});
// ── サービス死活チェックプロキシ (/api/check?url=...) ──────────
// ブラウザの mixed-content 制限を回避するためサーバー側から HTTP チェック
// SSRF 対策: http/https のみ許可、クラウドメタデータ IP をブロック
const BLOCKED_HOSTS = /^(169\.254\.|100\.100\.100\.100|metadata\.google\.internal)/;
function isCheckUrlAllowed(raw) {
let parsed;
try { parsed = new URL(raw); } catch { return false; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (BLOCKED_HOSTS.test(parsed.hostname)) return false;
return true;
}
app.get('/api/check', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
const { url } = req.query;
if (!url) return res.status(400).json({ ok: false, error: 'url required' });
if (!isCheckUrlAllowed(url)) return res.status(400).json({ ok: false, error: 'url not allowed' });
const t0 = Date.now();
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);
const r = await fetch(url, { method: 'HEAD', signal: ctrl.signal });
clearTimeout(timer);
res.json({ ok: true, status: r.status, latency_ms: Date.now() - t0 });
} catch (e) {
res.json({ ok: false, error: e.message, latency_ms: Date.now() - t0 });
}
});
// Tailscale証明書を自動検出
function findCert() {
const home = os.homedir();
const crt = fs.readdirSync(home).find((f) => f.endsWith('.crt'));
if (!crt) return null;
const key = crt.replace('.crt', '.key');
if (!fs.existsSync(path.join(home, key))) return null;
return { cert: fs.readFileSync(path.join(home, crt)), key: fs.readFileSync(path.join(home, key)) };
}
const tlsOpts = findCert();
const server = tlsOpts ? https.createServer(tlsOpts, app) : http.createServer(app);
const proto = tlsOpts ? 'https' : 'http';
const wss = new WebSocketServer({ server, path: '/terminal' });
wss.on('connection', (ws) => {
// セッションID・ログファイル作成
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const logPath = path.join(SESSIONS_DIR, `${sessionId}.log`);
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
logStream.write(`=== posimai-dev session ${sessionId} ===\n`);
// セッションID をブラウザに通知
ws.send(JSON.stringify({ type: 'session', id: sessionId }));
const shell = process.env.SHELL || '/bin/bash';
const ptyProc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd: path.join(os.homedir(), 'posimai-project'),
env: { ...process.env, PATH: `${os.homedir()}/.npm-global/bin:${process.env.PATH}` }
});
// 起動時に posimai-project へ自動移動cwd で指定済みだが source bashrc も通す)
setTimeout(() => ptyProc.write(`source ~/.bashrc\n`), 300);
ptyProc.onData((data) => {
if (!logStream.destroyed) logStream.write(data);
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
});
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString());
if (msg.type === 'input') {
logStream.write(msg.data);
ptyProc.write(msg.data);
}
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
} catch {}
});
ws.on('close', () => {
logStream.end(`\n=== session end ===\n`);
try { ptyProc.kill(); } catch {}
});
ptyProc.onExit(() => { try { ws.close(); } catch {} });
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`posimai-dev running on ${proto}://0.0.0.0:${PORT}`);
});