feat(webui): режим console (только RS232-консоль, питание отключено)

- ATXPI_MODE=console: /api/power/* → 403, кнопки питания скрыты в UI
- /api/status отдаёт mode; env-шаблон и install.sh учитывают ATXPI_MODE
- нужно для безопасного запуска панели на серверах-«управленцах» без риска питания
This commit is contained in:
toros
2026-09-08 23:34:23 +05:00
parent e3f7865ce6
commit 49b6cf89f4
5 changed files with 30 additions and 6 deletions
+3
View File
@@ -5,6 +5,9 @@
# Драйвер железа: real | mock # Драйвер железа: real | mock
ATXPI_DRIVER=real ATXPI_DRIVER=real
# Режим: full | console. В console питание отключено (только RS232-консоль).
ATXPI_MODE=full
# RS232-консоль сервера # RS232-консоль сервера
SERIAL_PORT=/dev/ttyUSB0 SERIAL_PORT=/dev/ttyUSB0
SERIAL_BAUD=115200 SERIAL_BAUD=115200
+14 -1
View File
@@ -22,7 +22,7 @@ import os
import time import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -34,9 +34,16 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(mess
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
WEB_TOKEN = os.environ.get("ATXPI_WEB_TOKEN", "") WEB_TOKEN = os.environ.get("ATXPI_WEB_TOKEN", "")
MODE = os.environ.get("ATXPI_MODE", "full") # full | console (только консоль, питание отключено)
AUDIT = os.environ.get("ATXPI_AUDIT", "/etc/atxpi/audit.jsonl") AUDIT = os.environ.get("ATXPI_AUDIT", "/etc/atxpi/audit.jsonl")
def _power_guard():
"""В режиме console питание выключено — деструктивные действия запрещены."""
if MODE == "console":
raise HTTPException(status_code=403, detail="console-only mode: power disabled")
def audit(action: str, **kw): def audit(action: str, **kw):
"""Логируем действие в jsonl (для /api/history).""" """Логируем действие в jsonl (для /api/history)."""
entry = {"t": time.time(), "action": action, **kw} entry = {"t": time.time(), "action": action, **kw}
@@ -77,29 +84,34 @@ async def auth_mw(request: Request, call_next):
# ── REST: питание ────────────────────────────────────────────────────────── # ── REST: питание ──────────────────────────────────────────────────────────
@app.get("/api/power") @app.get("/api/power")
async def api_power_status(): async def api_power_status():
_power_guard()
return power_mod.status() return power_mod.status()
@app.post("/api/power/on") @app.post("/api/power/on")
async def api_power_on(): async def api_power_on():
_power_guard()
audit("power_on") audit("power_on")
return power_mod.press_power() return power_mod.press_power()
@app.post("/api/power/off") @app.post("/api/power/off")
async def api_power_off(): async def api_power_off():
_power_guard()
audit("power_off") audit("power_off")
return power_mod.force_off() return power_mod.force_off()
@app.post("/api/power/reset") @app.post("/api/power/reset")
async def api_power_reset(): async def api_power_reset():
_power_guard()
audit("power_reset") audit("power_reset")
return power_mod.press_reset() return power_mod.press_reset()
@app.post("/api/power/cycle") @app.post("/api/power/cycle")
async def api_power_cycle(): async def api_power_cycle():
_power_guard()
audit("power_cycle") audit("power_cycle")
return power_mod.cycle() return power_mod.cycle()
@@ -113,6 +125,7 @@ async def api_status():
"driver": DRIVER, "driver": DRIVER,
"serial_port": SERIAL_PORT, "serial_port": SERIAL_PORT,
"serial_baud": SERIAL_BAUD, "serial_baud": SERIAL_BAUD,
"mode": MODE,
} }
+4 -1
View File
@@ -28,7 +28,10 @@ async function refresh() {
document.getElementById('powerState').textContent = document.getElementById('powerState').textContent =
s.power === 'on' ? '🟢 ON' : '🔴 OFF'; s.power === 'on' ? '🟢 ON' : '🔴 OFF';
document.getElementById('status').textContent = document.getElementById('status').textContent =
`driver=${s.driver} · serial=${s.serial_port}@${s.serial_baud}`; `driver=${s.driver} · serial=${s.serial_port}@${s.serial_baud}` +
(s.mode === 'console' ? ' · режим: только консоль' : '');
const pc = document.getElementById('powerControls');
if (pc) pc.style.display = (s.mode === 'console') ? 'none' : '';
} catch (e) { /* уже обработано в api() */ } } catch (e) { /* уже обработано в api() */ }
} }
+6 -4
View File
@@ -25,10 +25,12 @@
<h1>⚡ atxpi</h1> <h1>⚡ atxpi</h1>
<span id="powerState" class="badge"></span> <span id="powerState" class="badge"></span>
<span class="spacer"></span> <span class="spacer"></span>
<button class="btn" onclick="doPower('on')" title="Нажать кнопку питания">⏻ ON</button> <span id="powerControls">
<button class="btn" onclick="doPower('off')" title="Принудительно выключить (долгое нажатие)"> OFF</button> <button class="btn" onclick="doPower('on')" title="Нажать кнопку питания"> ON</button>
<button class="btn" onclick="doPower('reset')" title="Аппаратный сброс">↻ Reset</button> <button class="btn" onclick="doPower('off')" title="Принудительно выключить (долгое нажатие)">⭘ OFF</button>
<button class="btn" onclick="doPower('cycle')" title="Жёсткий перезапуск">⟳ Cycle</button> <button class="btn" onclick="doPower('reset')" title="Аппаратный сброс">↻ Reset</button>
<button class="btn" onclick="doPower('cycle')" title="Жёсткий перезапуск">⟳ Cycle</button>
</span>
</header> </header>
<section id="status" class="status-line"></section> <section id="status" class="status-line"></section>
+3
View File
@@ -29,6 +29,7 @@ ATXPI_BIND_PORT="${ATXPI_BIND_PORT:-8443}"
ATXPI_SERIAL="${ATXPI_SERIAL:-/dev/ttyUSB0}" ATXPI_SERIAL="${ATXPI_SERIAL:-/dev/ttyUSB0}"
ATXPI_BAUD="${ATXPI_BAUD:-115200}" ATXPI_BAUD="${ATXPI_BAUD:-115200}"
ATXPI_DRIVER="${ATXPI_DRIVER:-real}" ATXPI_DRIVER="${ATXPI_DRIVER:-real}"
ATXPI_MODE="${ATXPI_MODE:-full}"
log() { echo -e "\e[1;32m[atxpi]\e[0m $*"; } log() { echo -e "\e[1;32m[atxpi]\e[0m $*"; }
err() { echo -e "\e[1;31m[atxpi]\e[0m $*" >&2; } err() { echo -e "\e[1;31m[atxpi]\e[0m $*" >&2; }
@@ -99,6 +100,8 @@ if [ ! -f "$ENV_FILE" ]; then
# atxpi — локальная конфигурация (создаётся install.sh, НЕ хранится в git) # atxpi — локальная конфигурация (создаётся install.sh, НЕ хранится в git)
# ATXPI_DRIVER: real | mock # ATXPI_DRIVER: real | mock
ATXPI_DRIVER=$ATXPI_DRIVER ATXPI_DRIVER=$ATXPI_DRIVER
# Режим: full | console (в console питание отключено)
ATXPI_MODE=$ATXPI_MODE
SERIAL_PORT=$ATXPI_SERIAL SERIAL_PORT=$ATXPI_SERIAL
SERIAL_BAUD=$ATXPI_BAUD SERIAL_BAUD=$ATXPI_BAUD
# токен для REST-API и браузера (генерируется при установке) # токен для REST-API и браузера (генерируется при установке)