Files
claude-bot 005c7bf44c installer-pf: accurate CGNAT detection via router WAN vs public IP
리뷰 지적 반영. 기존엔 HTTP 공인 egress IP 만 100.64/10 검사라, 전형적 CGNAT
(egress 는 ISP 공인, 라우터 WAN 만 100.64)을 놓치고 "CGNAT 아님"처럼 오진 가능.
- 라우터 WAN(IGD 외부) IP 를 UPnP 로 조회해 HTTP 공인 IP 와 비교.
- cgnat 를 3-state(yes/no/unknown)로: WAN 이 사설/CGNAT 이거나 공인 IP 와 다르면
  yes, 같으면 no, WAN 못 읽으면 unknown.
- unknown 이면 "외부 IP만으론 CGNAT 확정/배제 불가, 라우터 WAN 확인" 안내.
- WAN 조회 타임아웃 8s→6s. v0.3.17.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-05 21:26:13 +09:00

173 lines
6.2 KiB
JavaScript

'use strict'
const api = window.pfTool
let I18N = {}
function tt(key, params) {
var parts = String(key).split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) {
cur = cur[parts[i]]
} else {
return key
}
}
if (typeof cur !== 'string') return key
if (!params) return cur
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return name in params ? String(params[name]) : '{{' + name + '}}'
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;'
})
}
const pageHost = document.getElementById('pageHost')
const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = tt('logViewer.collapse')
}
})
api.onLog(function (line) {
logViewer.hidden = false
logBody.textContent += line + '\n'
logBody.scrollTop = logBody.scrollHeight
})
function applyStaticI18n() {
document.title = tt('app.title')
var h1 = document.querySelector('.appHeader h1')
if (h1) h1.textContent = tt('app.title')
var logH2 = logViewer.querySelector('header h2')
if (logH2) logH2.textContent = tt('logViewer.heading')
logToggle.textContent = tt('logViewer.collapse')
}
function renderMain() {
pageHost.innerHTML =
'<section class="page">' +
' <h2>' + escapeHtml(tt('main.heading')) + '</h2>' +
' <p class="formMessage">' + escapeHtml(tt('main.intro')) + '</p>' +
' <div class="fieldset">' +
' <label for="port">' + escapeHtml(tt('main.portLabel')) + '</label>' +
' <input type="text" id="port" inputmode="numeric" value="25565" />' +
' </div>' +
' <div class="actionRow">' +
' <button class="primaryBtn" id="openBtn">' + escapeHtml(tt('main.openBtn')) + '</button>' +
' <button class="secondaryBtn" id="closeBtn">' + escapeHtml(tt('main.closeBtn')) + '</button>' +
' <button class="secondaryBtn" id="quitBtn">' + escapeHtml(tt('main.quitBtn')) + '</button>' +
' </div>' +
' <div id="result"></div>' +
'</section>'
var portEl = document.getElementById('port')
var openBtn = document.getElementById('openBtn')
var closeBtn = document.getElementById('closeBtn')
var quitBtn = document.getElementById('quitBtn')
var resultEl = document.getElementById('result')
function parsePort() {
var n = parseInt(String(portEl.value).replace(/[^0-9]/g, ''), 10)
if (!isFinite(n) || n <= 0 || n >= 65536) return 25565
return n
}
function setBusy(busy) {
openBtn.disabled = busy
closeBtn.disabled = busy
portEl.disabled = busy
}
function showResult(outcome) {
var addr = (outcome.externalIp || tt('main.ipUnknown')) +
(outcome.port === 25565 ? '' : (':' + outcome.port))
var cls, badge, msg
if (outcome.reachable === true) {
cls = 'ok'; badge = tt('verdict.success')
msg = tt('main.successMsg', { addr: addr }) +
(outcome.preForwarded ? ' ' + tt('main.alreadyOpen') : '')
} else if (outcome.reachable === false) {
cls = 'fail'; badge = tt('verdict.fail')
msg = tt('main.failMsg')
} else {
cls = 'warn'; badge = tt('verdict.unknown')
msg = tt('main.unknownMsg')
}
// 실패/확인불가일 때 다음 조치 안내: CGNAT 경고 + 수동 포워딩 방법.
var extra = ''
if (outcome.reachable !== true) {
if (outcome.cgnat === 'yes') {
extra += '<p class="formMessage error" style="margin-top:8px;">' +
escapeHtml(tt('main.cgnatWarn', { public: outcome.externalIp || '?', wan: outcome.wanIp || '?' })) + '</p>'
} else if (outcome.cgnat === 'unknown') {
extra += '<p class="formMessage" style="margin-top:8px;">' +
escapeHtml(tt('main.cgnatUnknown', { ip: outcome.externalIp || '?' })) + '</p>'
}
var manual = outcome.localIp
? tt('main.manualForward', { port: outcome.port, localIp: outcome.localIp })
: tt('main.manualForwardNoIp', { port: outcome.port })
extra += '<p class="formMessage" style="margin-top:8px;">' + escapeHtml(manual) + '</p>'
}
resultEl.innerHTML =
'<div class="progressCard ' + (cls === 'ok' ? 'done' : cls === 'fail' ? 'error' : 'running') + '" style="margin-top:14px;">' +
' <div class="cardTop"><span class="statusBadge ' + cls + '">' + escapeHtml(badge) + '</span> ' +
' <span class="label">' + escapeHtml(addr) + '</span></div>' +
' <p class="formMessage" style="margin-top:8px;">' + escapeHtml(msg) + '</p>' +
extra +
' <p class="formMessage"><small>' + escapeHtml(tt('main.detailLabel', { detail: outcome.detail || '' })) + '</small></p>' +
'</div>'
}
openBtn.addEventListener('click', function () {
var port = parsePort()
portEl.value = String(port)
setBusy(true)
resultEl.innerHTML = '<p class="formMessage" style="margin-top:14px;">' + escapeHtml(tt('main.working')) + '</p>'
api.openPort(port).then(function (outcome) {
showResult(outcome)
}).catch(function (err) {
resultEl.innerHTML = '<p class="formMessage error" style="margin-top:14px;">' +
escapeHtml(tt('main.error', { message: (err && err.message) || String(err) })) + '</p>'
}).then(function () {
setBusy(false)
})
})
closeBtn.addEventListener('click', function () {
var port = parsePort()
portEl.value = String(port)
setBusy(true)
api.closePort(port).then(function () {
resultEl.innerHTML = '<p class="formMessage" style="margin-top:14px;">' +
escapeHtml(tt('main.closed', { port: port })) + '</p>'
}).then(function () {
setBusy(false)
})
})
quitBtn.addEventListener('click', function () { api.quit() })
}
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderMain()
})()