'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エラーになるためサーバー経由でプロキシ app.get('/api/vps-health', async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); try { const r = await fetch('https://api.soar-enrich.com/api/health', { 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}`); });