From 4a02f7f354694235fa8b680afac89943aebb24c4 Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 7 Jun 2026 13:59:12 +0900 Subject: [PATCH] Add guarded reboot control --- README.md | 9 ++- public/api.php | 45 ++++++++++++ public/assets/app.js | 150 ++++++++++++++++++++++++++++++++++++++ public/assets/wakelock.js | 6 +- public/index.php | 19 ++++- public/push_subscribe.js | 25 +++++-- 6 files changed, 244 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fcd3b3e..510ac7d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - Web Push 구독 등록, 자동 복구, 구독 기기 목록 표시 - Push 기기 건강 상태 추적과 헬스체크 수동/자동 실행 - WakeLock 버튼으로 대시보드 화면 꺼짐 방지 +- Reboot 버튼으로 2단계 확인 후 시스템 재부팅 요청 +- 기본 브라우저 alert/confirm/prompt 대신 대시보드 디자인에 맞춘 custom dialog 사용 ## 주요 API @@ -32,6 +34,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 - `public/api.php?action=send_push_healthcheck`: 등록된 Push 기기 전체에 상태 확인 알림 발송 - `public/api.php?action=delete_push_endpoint`: 현재 endpoint 구독 삭제 - `public/api.php?action=dmesg`: dmesg 로그 조회 +- `public/api.php?action=reboot`: 확인 단어와 관리자 암호 재검증 후 sudo reboot 실행 ## 구성 @@ -116,7 +119,8 @@ control-push-healthcheck.service 4. 팬 조작은 상태 저장, 정책 적용, 로그 저장 순서로 처리합니다. 5. Push 등록은 권한 요청과 구독 저장이 끝나면 즉시 상태를 갱신합니다. 6. WakeLock 버튼은 활성 상태를 초록색 버튼으로 표시합니다. -7. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. +7. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. +8. Push 발송, 수신, 표시, 클릭 이벤트를 기록하고 구독 DB의 건강 상태를 갱신합니다. ## 주요 함수/모듈 @@ -127,12 +131,14 @@ control-push-healthcheck.service - `push_health_summary()`: 상태별 Push 기기 수 집계 - `send_push_healthcheck_if_due()`: 쿨다운 기반 헬스체크 알림 발송 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 +- `customAlert()`, `customConfirm()`, `customPrompt()`: 대시보드 공통 확인/입력 dialog ## 보안 - 로그인 세션과 CSRF token을 사용합니다. - POST 조작 API는 CSRF 검증을 통과해야 합니다. - sysfs 쓰기와 systemctl 실행 권한은 sudoers 범위로 제한해야 합니다. +- 재부팅 API는 CSRF, 로그인 세션, 확인 단어 `재부팅`, 관리자 암호 재검증을 모두 요구합니다. - VAPID key와 앱 비밀번호는 저장소 밖 secret 파일로 관리합니다. ## 운영 체크포인트 @@ -143,3 +149,4 @@ control-push-healthcheck.service - Push 구독 자동 복구가 과도한 반복 등록을 만들지 않는지 확인합니다. - Push 헬스체크 timer 상태는 `systemctl list-timers --all control-push-healthcheck.timer`로 확인합니다. - `failed`, `stale` 기기는 알림 권한, 브라우저 데이터 삭제, PWA 재설치 여부를 확인합니다. +- Reboot API 사용 전 웹 서버 실행 계정의 sudoers에 `/usr/sbin/reboot` 비밀번호 없는 실행 권한이 제한적으로 설정되어 있는지 확인합니다. diff --git a/public/api.php b/public/api.php index 8fc6831..bc67093 100644 --- a/public/api.php +++ b/public/api.php @@ -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'] ?? ''); diff --git a/public/assets/app.js b/public/assets/app.js index d880a32..ac7eddf 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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' })) { diff --git a/public/assets/wakelock.js b/public/assets/wakelock.js index 26e2669..433de34 100644 --- a/public/assets/wakelock.js +++ b/public/assets/wakelock.js @@ -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(); }); }); diff --git a/public/index.php b/public/index.php index bf66d04..63c01dd 100644 --- a/public/index.php +++ b/public/index.php @@ -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}} @@ -136,6 +137,7 @@ a{color:inherit;text-decoration:none}
+ Logout
@@ -283,9 +285,20 @@ a{color:inherit;text-decoration:none}
- - - + + + + diff --git a/public/push_subscribe.js b/public/push_subscribe.js index 9aa0229..daf8e5c 100644 --- a/public/push_subscribe.js +++ b/public/push_subscribe.js @@ -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(() => {}); }); });