Add guarded reboot control

This commit is contained in:
seo
2026-06-07 13:59:12 +09:00
parent d6b1322a8f
commit 4a02f7f354
6 changed files with 244 additions and 10 deletions
+45
View File
@@ -1751,6 +1751,51 @@ function control_api_dispatch(): void
]);
}
if ($action === 'reboot') {
$phrase = trim((string)($_POST['phrase'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if ($phrase !== '재부팅') {
json_out([
'ok' => false,
'error' => 'bad_reboot_phrase',
'message' => '재부팅 확인 단어가 올바르지 않습니다.',
], 400);
}
if (!hash_equals(APP_PASSWORD, $password)) {
json_out([
'ok' => false,
'error' => 'bad_reboot_password',
'message' => '관리자 암호가 올바르지 않습니다.',
], 403);
}
add_fan_action(
'system_reboot',
null,
null,
'web reboot requested',
true
);
$result = sh(['/usr/sbin/reboot'], true, 5);
if ($result['code'] !== 0) {
json_out([
'ok' => false,
'error' => 'reboot_failed',
'message' => trim($result['out']) !== '' ? trim($result['out']) : 'reboot command failed',
], 500);
}
json_out([
'ok' => true,
'data' => [
'message' => 'reboot_requested',
],
]);
}
if ($action === 'wifi') {
$verb = (string)($_POST['verb'] ?? '');
$unit = (string)($_POST['unit'] ?? '');
+150
View File
@@ -29,6 +29,13 @@
pushStatus: $('#pushStatus'),
pushDeviceList: $('#pushDeviceList'),
pushHealthcheckBtn: $('#pushHealthcheckBtn'),
rebootBtn: $('#rebootBtn'),
customDialog: $('#customDialog'),
customDialogTitle: $('#customDialogTitle'),
customDialogMessage: $('#customDialogMessage'),
customDialogInput: $('#customDialogInput'),
customDialogCancel: $('#customDialogCancel'),
customDialogOk: $('#customDialogOk'),
processCpuTable: $('#processCpuTable'),
processMemoryTable: $('#processMemoryTable'),
dmesgToggle: $('#dmesgToggle'),
@@ -111,6 +118,97 @@
}, 2600);
}
function customDialog(options = {}) {
const layer = els.customDialog;
const input = els.customDialogInput;
const ok = els.customDialogOk;
const cancel = els.customDialogCancel;
if (!layer || !input || !ok || !cancel) {
return Promise.resolve(null);
}
return new Promise(resolve => {
const mode = options.mode || 'alert';
const needsInput = mode === 'prompt';
const previousFocus = document.activeElement;
let settled = false;
const cleanup = value => {
if (settled) return;
settled = true;
layer.dataset.open = '0';
layer.setAttribute('aria-hidden', 'true');
ok.classList.remove('red');
ok.textContent = '확인';
cancel.textContent = '취소';
input.value = '';
input.hidden = true;
input.type = 'text';
ok.removeEventListener('click', onOk);
cancel.removeEventListener('click', onCancel);
layer.removeEventListener('click', onLayerClick);
document.removeEventListener('keydown', onKeydown);
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus();
}
resolve(value);
};
const onOk = () => {
if (needsInput) {
cleanup(input.value);
return;
}
cleanup(true);
};
const onCancel = () => cleanup(mode === 'confirm' ? false : null);
const onLayerClick = event => {
if (event.target === layer) onCancel();
};
const onKeydown = event => {
if (event.key === 'Escape') onCancel();
if (event.key === 'Enter' && (document.activeElement === input || !needsInput)) onOk();
};
els.customDialogTitle.textContent = options.title || '확인';
els.customDialogMessage.textContent = options.message || '';
ok.textContent = options.okText || '확인';
cancel.textContent = options.cancelText || '취소';
ok.classList.toggle('red', options.danger === true);
cancel.hidden = mode === 'alert';
input.hidden = !needsInput;
input.type = options.inputType || 'text';
input.placeholder = options.placeholder || '';
input.autocomplete = options.autocomplete || 'off';
input.value = options.defaultValue || '';
ok.addEventListener('click', onOk);
cancel.addEventListener('click', onCancel);
layer.addEventListener('click', onLayerClick);
document.addEventListener('keydown', onKeydown);
layer.dataset.open = '1';
layer.setAttribute('aria-hidden', 'false');
setTimeout(() => (needsInput ? input : ok).focus(), 20);
});
}
window.customAlert = (message, options = {}) => customDialog(Object.assign({
mode: 'alert',
title: '알림',
message,
}, options));
window.customConfirm = (message, options = {}) => customDialog(Object.assign({
mode: 'confirm',
title: '확인',
message,
}, options));
window.customPrompt = (message, options = {}) => customDialog(Object.assign({
mode: 'prompt',
title: '입력',
message,
}, options));
async function api(action, body = null) {
const opts = {
method: body ? 'POST' : 'GET',
@@ -1081,6 +1179,55 @@
}
}
async function requestReboot() {
const phrase = await window.customPrompt('재부팅을 계속하려면 아래에 재부팅을 입력하세요.', {
title: '시스템 재부팅',
okText: '다음',
danger: true,
placeholder: '재부팅',
autocomplete: 'off',
});
if (phrase === null) return;
if (String(phrase).trim() !== '재부팅') {
await window.customAlert('확인 단어가 일치하지 않습니다.', {
title: '재부팅 취소',
danger: true,
});
return;
}
const password = await window.customPrompt('관리자 암호를 다시 입력하세요.', {
title: '관리자 확인',
okText: '재부팅',
danger: true,
inputType: 'password',
autocomplete: 'current-password',
});
if (password === null) return;
try {
els.rebootBtn.disabled = true;
notice('Reboot command sending...', 'info');
await api('reboot', {
phrase: '재부팅',
password,
});
await window.customAlert('재부팅 명령을 전송했습니다. 잠시 후 연결이 끊어질 수 있습니다.', {
title: '재부팅 시작',
okText: '확인',
});
} catch (e) {
console.error(e);
els.rebootBtn.disabled = false;
await window.customAlert(e.message || 'reboot failed', {
title: '재부팅 실패',
danger: true,
});
}
}
if (els.fanSlider && els.fanSliderValue) {
els.fanSlider.addEventListener('input', () => {
markFanDirty();
@@ -1104,6 +1251,9 @@
els.dmesgToggle?.addEventListener('click', () => {
setDmesgOpen(!state.dmesgOpen);
});
els.rebootBtn?.addEventListener('click', () => {
requestReboot();
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
if (!sendWs({ type: 'status_refresh' })) {
+5 -1
View File
@@ -55,7 +55,11 @@
job.catch(error => {
wanted = false;
wakeLock = null;
alert(error.message || 'WakeLock failed');
const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'WakeLock failed', {
title: 'WakeLock 오류',
danger: true,
});
render();
});
});
+16 -3
View File
@@ -107,6 +107,7 @@ a{color:inherit;text-decoration:none}
.resource-card{margin-top:18px}.resource-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:16px}.resource-head h2{margin:0}.baseline-pill{color:var(--sub);font-size:12px;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.spike-log-box{margin-top:16px}.spike-log-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.spike-log-list{display:grid;gap:8px;max-height:260px;overflow:auto;padding-right:4px}.spike-log-item{display:grid;gap:4px;padding:10px 12px;border:1px solid rgba(255,204,0,.35);background:rgba(138,109,27,.16);border-radius:12px}.spike-log-item.latest{border:1px solid rgba(255,204,0,.58);background:rgba(138,109,27,.28);box-shadow:0 0 0 1px rgba(255,204,0,.14) inset;}.spike-log-item strong{font-size:13px;font-weight:500;color:#f3e3a3}.spike-log-item span{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.spike-log-empty{padding:10px 12px;border-radius:12px;background:var(--card2);color:var(--sub);font-size:13px}.dmesg-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmesg-meta{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}.dmesg-log{margin:10px 0 0;max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:12px;background:#070a10;color:#d9e2f2;padding:12px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word}.dmesg-log[hidden]{display:none}
.resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.resource-box{min-width:0}.resource-box h3{margin:0 0 9px;color:#c6d3e6;font-size:14px;font-weight:500}.resource-table{width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;border:1px solid var(--line);border-radius:14px;overflow:hidden}.resource-table th,.resource-table td{padding:9px 10px;border-bottom:1px solid rgba(255,255,255,.06);background:#11151d;text-align:left;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.resource-table th{background:#1d2330;color:#c6d3e6;font-weight:500}.resource-table .pid{width:72px}.resource-table .metric{width:82px}.resource-table .service{width:150px}.resource-table .cmd{width:auto}.push-device-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}.push-device-head h3{margin:0}.push-device-actions{display:flex;gap:8px;flex-wrap:wrap}.push-device-list{display:grid;gap:8px;margin-top:8px}.push-device-row{padding:10px 12px;border:1px solid rgba(84,101,128,.58);background:#11151d;border-radius:12px}.push-device-row.healthy{border-color:rgba(53,196,107,.45)}.push-device-row.watch{border-color:rgba(255,204,0,.48)}.push-device-row.stale,.push-device-row.failed{border-color:rgba(255,95,87,.55)}.push-device-main{min-width:0;color:var(--sub);font-size:12px;line-height:1.45;display:grid;gap:2px;word-break:break-all}.push-device-main strong{color:#edf1f7;font-size:13px}.push-device-badge{display:inline-flex;align-items:center;width:max-content;border-radius:999px;padding:2px 8px;background:#2b3342;color:#edf1f7;font-size:11px}.push-device-badge.healthy{background:#198754}.push-device-badge.watch{background:#8a6d1b}.push-device-badge.stale,.push-device-badge.failed{background:#c62828}
.notice{position:fixed;right:20px;bottom:20px;min-width:220px;max-width:420px;padding:14px 16px;border-radius:14px;font-weight:500;background:#1d2330;border:1px solid var(--line);box-shadow:var(--shadow);z-index:99}.notice:empty{display:none}.notice[data-type=success]{border-color:rgba(53,196,107,.5)}.notice[data-type=error]{border-color:rgba(255,95,87,.5)}
.dialog-layer{position:fixed;inset:0;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(4,7,12,.68);backdrop-filter:blur(8px);z-index:120}.dialog-layer[data-open="1"]{display:flex}.dialog-box{width:min(430px,100%);border:1px solid rgba(84,101,128,.78);border-radius:20px;background:#171a21;box-shadow:0 24px 70px rgba(0,0,0,.42);padding:20px}.dialog-box h3{margin:0 0 8px;font-size:19px;font-weight:500}.dialog-message{margin:0 0 16px;color:var(--sub);line-height:1.5;white-space:pre-wrap}.dialog-input{width:100%;height:48px;margin-bottom:14px;border-radius:14px;border:1px solid var(--line);background:#11151d;color:var(--text);padding:0 14px;outline:none}.dialog-input:focus{outline:2px solid rgba(59,130,246,.65);outline-offset:2px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px}.dialog-actions .btn{min-width:84px}.dialog-actions .btn.red{background:#c62828}
@media(max-width:1320px){.chart-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:1100px){.layout{grid-template-columns:1fr}.chart-grid,.resource-grid{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column}.topbar-right{width:100%}.topbar-right .btn{flex:1}.stat-grid{grid-template-columns:1fr}.resource-head{align-items:flex-start;flex-direction:column}.baseline-pill{text-align:left;white-space:normal}}
</style>
@@ -136,6 +137,7 @@ a{color:inherit;text-decoration:none}
<div class="topbar-right">
<button id="pushEnableBtn" class="btn secondary" type="button">Push</button>
<button id="wakeLockBtn" class="btn secondary" type="button">WakeLock</button>
<button id="rebootBtn" class="btn warn" type="button">Reboot</button>
<a href="/?logout=1" class="btn red">Logout</a>
</div>
</div>
@@ -283,9 +285,20 @@ a{color:inherit;text-decoration:none}
</section>
</div>
<div id="notice" class="notice"></div>
<script src="/assets/app.js?v=20260607_pushhealth1"></script>
<script src="/assets/wakelock.js?v=20260606_wakelock2"></script>
<script src="/push_subscribe.js?v=20260606_noappend1"></script>
<div id="customDialog" class="dialog-layer" aria-hidden="true">
<div class="dialog-box" role="dialog" aria-modal="true" aria-labelledby="customDialogTitle">
<h3 id="customDialogTitle">확인</h3>
<p id="customDialogMessage" class="dialog-message"></p>
<input id="customDialogInput" class="dialog-input" type="text" autocomplete="off" hidden>
<div class="dialog-actions">
<button id="customDialogCancel" class="btn secondary" type="button">취소</button>
<button id="customDialogOk" class="btn" type="button">확인</button>
</div>
</div>
</div>
<script src="/assets/app.js?v=20260607_reboot1"></script>
<script src="/assets/wakelock.js?v=20260607_dialog1"></script>
<script src="/push_subscribe.js?v=20260607_dialog1"></script>
<?php endif; ?>
</body>
</html>
+20 -5
View File
@@ -32,8 +32,14 @@
return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length;
}
function deviceNameFromUser() {
const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', '');
async function deviceNameFromUser() {
const promptFn = window.customPrompt || (() => Promise.resolve(null));
const name = await promptFn('등록할 기기 이름을 입력하세요. 한글 2글자 이상이어야 합니다.', {
title: 'Push 등록',
okText: '등록',
placeholder: '휴대폰',
autocomplete: 'off',
});
if (name === null) {
return null;
@@ -251,7 +257,7 @@
return;
}
const deviceName = deviceNameFromUser();
const deviceName = await deviceNameFromUser();
if (deviceName === null) {
await refreshButton();
return;
@@ -283,7 +289,12 @@
}
async function unsubscribe() {
if (!confirm('푸시 기기 삭제할까요?')) {
const confirmFn = window.customConfirm || (() => Promise.resolve(false));
if (!await confirmFn('현재 브라우저의 Push 구독을 삭제할까요?', {
title: 'Push 삭제',
okText: '삭제',
danger: true,
})) {
await refreshButton();
return;
}
@@ -316,7 +327,11 @@
const active = button.dataset.active === '1';
const job = active ? unsubscribe() : subscribe();
job.catch(error => {
alert(error.message || 'Push failed');
const alertFn = window.customAlert || (() => Promise.resolve());
alertFn(error.message || 'Push failed', {
title: 'Push 오류',
danger: true,
});
refreshButton().catch(() => {});
});
});