From cf1da4a0a910677e73cb49616a6b1fbceefb0348 Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 6 Oct 2026 19:53:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D1=8E=D1=87:=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=20=D1=81=D0=BB=D1=83=D1=87=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB=20=D0=92=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B8=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D1=83=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=E2=80=94=20=D1=87=D0=B5=D0=BB=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D0=BA=20=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=20=D0=BC=D1=8B=D1=88?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=B2=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5?= =?UTF-8?q?=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На сервере без экрана окно Валидаты «Пожалуйста, двигайте мышью» никто не видел, ключ в тихом режиме падал с 0x80090022 (ИШ: «Не найден ключ ЭП (E0700009)»), пересборка справочника — тоже. bj-validata-rng: тихая проверка датчика (testcsp -silent -random), сеанс на виртуальном экране, движения из поля мастера повторяются в окне Валидаты (XTEST), прогресс — по полосе её окна. Установщик ключа проверяет датчик до записи на флешку. zpki.ini: Count = числу профилей, блоки подряд. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- deploy/linux/sbin/bj-keymedia-install | 25 +- deploy/linux/sbin/bj-keymedia-worker | 2 + deploy/linux/sbin/bj-validata-rng | 220 ++++++++++++++++++ internal/lkgateway/setup.go | 1 + internal/lkgateway/validatarng.go | 198 ++++++++++++++++ internal/lkgateway/validatarng_test.go | 54 +++++ .../lkgateway/web/templates/part_keywiz.html | 66 ++++++ 7 files changed, 559 insertions(+), 7 deletions(-) create mode 100755 deploy/linux/sbin/bj-validata-rng create mode 100644 internal/lkgateway/validatarng.go create mode 100644 internal/lkgateway/validatarng_test.go diff --git a/deploy/linux/sbin/bj-keymedia-install b/deploy/linux/sbin/bj-keymedia-install index 7b50ff0..97cdd3e 100755 --- a/deploy/linux/sbin/bj-keymedia-install +++ b/deploy/linux/sbin/bj-keymedia-install @@ -186,6 +186,12 @@ if [ "$ACTION" = "inspect" ]; then fi [ "$ACTION" = "install" ] || err "неизвестное действие" +# датчик случайных чисел Валидаты: пока он не инициализирован (окно «двигайте мышью» на сервере без экрана никто +# не видит), ключ в тихом режиме не работает — ни пересборка справочника, ни подпись ИШ. Проверяем до записи на флешку. +if [ -x /usr/local/sbin/bj-validata-rng ] && /usr/local/sbin/bj-validata-rng status 2>/dev/null | grep -q '"ready":false'; then + err "датчик случайных чисел Валидаты не инициализирован — сначала пройдите «Датчик случайных чисел» на этом шаге (поводить мышью в поле)" +fi + # ---- целевая точка монтирования (флешка) ---- TMP_MNT=""; UMOUNT_AFTER=0 if [ "$NOWRITE" = 1 ]; then @@ -286,16 +292,21 @@ for l in L: try: idx=max(idx,int(s[len("[Profile_"):-1])) except: pass n=idx+1 -# нарастить Count в секции верхнеуровневых профилей (последний 'Count = K' до первого Profile_) -firstp=next((k for k,l in enumerate(L) if l.strip().startswith("[Profile_")), len(L)) -for k in range(firstp-1,-1,-1): - if L[k].strip().startswith("Count = "): - try: L[k]=f"Count = {int(L[k].split('=')[1])+1}" - except: pass - break L+=[f"[Profile_{n}]",f"Name = {name}","Count = 2", f"store_0 = pse://signed//{igp}/local.pse", f"store_1 = file://{igp}/local.gdbm"] +# Count в секции [Profile] — число блоков [Profile_N] (раньше прибавлялась единица при каждой установке, +# и после переустановки того же профиля Count расходился с числом профилей) +# блоки — подряд с нуля: Валидата читает Profile_0…Profile_{Count-1}, дыра после удалённого блока прятала профиль +cnt=0 +for k,l in enumerate(L): + if l.strip().startswith("[Profile_") and l.strip().endswith("]"): + L[k]=f"[Profile_{cnt}]"; cnt+=1 +firstp=next((k for k,l in enumerate(L) if l.strip().startswith("[Profile_")), len(L)) +for k in range(firstp-1,-1,-1): + if L[k].strip().startswith("Count = "): + L[k]=f"Count = {cnt}" + break open(ini,"w").write("\n".join(L)) PY fi diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker index 7748ba0..626c7f1 100755 --- a/deploy/linux/sbin/bj-keymedia-worker +++ b/deploy/linux/sbin/bj-keymedia-worker @@ -13,6 +13,8 @@ process_one(){ component-install) out=$(/usr/local/sbin/bj-component-install "$staging" 2>&1) ;; # интеграционный шлюз НРД: состояние, установка, канал контура (TEST3 | боевой); параметры — JSON в extra igate-status|igate-install|igate-contour) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-igate-admin "${action#igate-}" 2>&1) ;; + # датчик случайных чисел Валидаты: проверка и сеанс «двигайте мышью» (движения — из мастера bj) + rng-status|rng-start|rng-cancel) out=$(/usr/local/sbin/bj-validata-rng "${action#rng-}" 2>&1) ;; # системные настройки сервера: серверы времени (NTP) system-ntp|system-tz) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;; # резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra diff --git a/deploy/linux/sbin/bj-validata-rng b/deploy/linux/sbin/bj-validata-rng new file mode 100755 index 0000000..bb68eac --- /dev/null +++ b/deploy/linux/sbin/bj-validata-rng @@ -0,0 +1,220 @@ +#!/bin/bash +# bj-validata-rng — датчик случайных чисел СКЗИ Валидата на сервере без экрана. +# +# Перед первой работой с ключом Валидата собирает случайность от движений мыши человека (окно «Пожалуйста, +# двигайте мышью…»); состояние датчика затем хранит служба ключей (vdcrysvc, /opt/Validata/VDCSP/.crysvc.seed), +# и это делается один раз для сервера. Без этого любая операция с ключом в «тихом» режиме (ИШ, пересборка +# справочника, проверка подписи) падает с 0x80090022 «контекст был получен как "тихий"» — у ИШ это выглядит как +# «Не найден ключ ЭП (E0700009)», хотя ключ на месте. +# +# У сервера экрана нет: окно Валидаты открывается на виртуальном экране (Xvfb), а движения мыши делает человек +# в браузере (мастер bj) — bj-server кладёт их пачками в $IN, сеанс повторяет их в окне (расширение XTEST) и +# пишет прогресс по полосе самого окна Валидаты (кадр экрана — через Xvfb -fbdir). Своей случайности сюда не +# подмешиваем: датчик биологический, источник — человек. +# status — {"ok":true,"ready":bool,"running":bool}: тихая проверка датчика (testcsp -silent -random) +# start — датчик не готов: запустить сеанс (служба bj-validata-rng, до 15 минут) +# cancel — остановить сеанс +# run — сам сеанс (запускается из start) +set -uo pipefail +VDBIN=/opt/Validata/VDCSP/bin/amd64 +VDLIB=/opt/Validata/VDCSP/lib/amd64 +D=/var/lib/bj/rng # status.json (читает bj-server) и in/ (движения от bj-server) +IN=$D/in +RUN=/run/bj-rng # виртуальный экран: кадр и ключ доступа к нему +UNIT=bj-validata-rng +IGUSER=igate; IGHOME=/var/lib/igate +ACTION="${1:-}" + +json_err(){ echo "{\"ok\":false,\"error\":\"$1\"}"; exit 1; } +id -u "$IGUSER" >/dev/null 2>&1 || json_err "нет пользователя ИШ ($IGUSER) — сначала установите интеграционный шлюз" +[ -x "$VDBIN/testcsp" ] || json_err "СКЗИ Валидата не установлена ($VDBIN/testcsp)" + +# служба ключей Валидаты: датчик живёт в ней (init-скрипт vdmkdev понимает только start/stop) +keysvc_up(){ + pgrep -x vdcrysvc >/dev/null && return 0 + systemctl daemon-reload >/dev/null 2>&1; systemctl enable vdmkdev.service >/dev/null 2>&1 + systemctl start vdmkdev.service >/dev/null 2>&1 || /etc/init.d/vdmkdev start >/dev/null 2>&1 + for _ in 1 2 3 4 5; do pgrep -x vdcrysvc >/dev/null && return 0; sleep 1; done + return 1 +} + +# rng_ready — 0: датчик готов (тихая генерация случайных данных прошла) +RNG_ERR="" +rng_ready(){ + local t o; t=$(mktemp -d); chown "$IGUSER" "$t" + o=$(cd "$t" && timeout -k 5 30 runuser -u "$IGUSER" -- env HOME="$IGHOME" LD_LIBRARY_PATH="$VDLIB" \ + "$VDBIN/testcsp" -silent -random -generate -block 32 -out "$t/r.bin" 2>&1) + if [ "$(stat -c %s "$t/r.bin" 2>/dev/null)" = 32 ]; then rm -rf "$t"; return 0; fi + rm -rf "$t" + RNG_ERR=$(printf '%s' "$o" | grep -E 'result' | tail -1 | sed 's/.*result: *//' | tr -d '"\\' | head -c 200) + return 1 +} + +running(){ systemctl is-active --quiet "$UNIT" 2>/dev/null; } + +case "$ACTION" in +status) + keysvc_up || json_err "служба ключей Валидаты (vdcrysvc) не запускается" + R=false; running && R=true + if rng_ready; then echo "{\"ok\":true,\"ready\":true,\"running\":$R}" + else echo "{\"ok\":true,\"ready\":false,\"running\":$R,\"detail\":\"$RNG_ERR\"}"; fi + exit 0 ;; +start) + keysvc_up || json_err "служба ключей Валидаты (vdcrysvc) не запускается" + running && { echo '{"ok":true,"started":true,"running":true}'; exit 0; } + rng_ready && { echo '{"ok":true,"ready":true}'; exit 0; } + systemctl reset-failed "$UNIT" >/dev/null 2>&1 + install -d -o root -g bj -m 0750 "$D"; install -d -o bj -g bj -m 0700 "$IN"; rm -f "$IN"/* + printf '{"state":"starting","progress":0,"at":%s}\n' "$(date +%s)" > "$D/status.json"; chmod 644 "$D/status.json" + if ! O=$(systemd-run --unit="$UNIT" --collect --property=RuntimeMaxSec=900 --quiet "$0" run 2>&1); then + json_err "сеанс датчика не запустился: $(printf '%s' "$O" | tr -d '"\\' | tr '\n' ' ' | head -c 200)" + fi + echo '{"ok":true,"started":true,"running":true}'; exit 0 ;; +cancel) + systemctl stop "$UNIT" >/dev/null 2>&1 + printf '{"state":"cancelled","progress":0,"at":%s}\n' "$(date +%s)" > "$D/status.json" 2>/dev/null + echo '{"ok":true}'; exit 0 ;; +run) : ;; +*) json_err "неизвестное действие" ;; +esac + +# ===== сеанс (служба bj-validata-rng) ===== +rm -rf "$RUN"; install -d -o "$IGUSER" -g "$IGUSER" -m 0700 "$RUN" "$RUN/fb" +N=""; for n in 93 94 95 96 97 98 99; do [ -e "/tmp/.X$n-lock" ] || [ -e "/tmp/.X11-unix/X$n" ] || { N=$n; break; }; done +st(){ printf '%s\n' "$1" > "$D/status.json.tmp"; chmod 644 "$D/status.json.tmp"; mv -f "$D/status.json.tmp" "$D/status.json"; } +[ -n "$N" ] || { st "{\"state\":\"failed\",\"error\":\"нет свободного номера виртуального экрана\",\"at\":$(date +%s)}"; exit 1; } +# ключ доступа к экрану (MIT-MAGIC-COOKIE-1, формат .Xauthority) — к экрану подключаются только окно Валидаты и сеанс +python3 - "$RUN/xauth" "$N" <<'PY' +import os,struct,sys +p,n=sys.argv[1],sys.argv[2].encode() +c=os.urandom(16); f=lambda b:struct.pack('>H',len(b))+b +open(p,'wb').write(struct.pack('>H',0xFFFF)+f(b'')+f(n)+f(b'MIT-MAGIC-COOKIE-1')+f(c)) +PY +chown "$IGUSER:$IGUSER" "$RUN/xauth"; chmod 600 "$RUN/xauth" +cleanup(){ [ -n "${TP:-}" ] && kill "$TP" 2>/dev/null; [ -n "${XP:-}" ] && kill "$XP" 2>/dev/null; sleep 0.5; rm -rf "$RUN"; rm -f "$IN"/* 2>/dev/null; } +trap cleanup EXIT +runuser -u "$IGUSER" -- Xvfb ":$N" -screen 0 640x480x24 -nolisten tcp -auth "$RUN/xauth" -fbdir "$RUN/fb" >/dev/null 2>&1 & XP=$! +for _ in $(seq 1 50); do [ -e "/tmp/.X11-unix/X$N" ] && [ -s "$RUN/fb/Xvfb_screen0" ] && break; sleep 0.1; done +[ -e "/tmp/.X11-unix/X$N" ] || { st "{\"state\":\"failed\",\"error\":\"виртуальный экран (Xvfb) не запустился\",\"at\":$(date +%s)}"; exit 1; } +# окно датчика Валидаты: генерация случайных данных НЕ в тихом режиме — Валидата сама просит двигать мышью +( cd "$RUN" && exec runuser -u "$IGUSER" -- env HOME="$IGHOME" DISPLAY=":$N" XAUTHORITY="$RUN/xauth" LD_LIBRARY_PATH="$VDLIB" \ + "$VDBIN/testcsp" -random -generate -block 32 -out "$RUN/r.bin" > "$RUN/testcsp.log" 2>&1 ) & TP=$! + +python3 - "$N" "$RUN" "$IN" "$D/status.json" "$TP" <<'PY' +# Повтор движений человека в окне Валидаты (XTEST) и прогресс по полосе её окна. Клиент X11 — свой, без +# xdotool/python-xlib: только соединение, QueryExtension и XTestFakeInput (движение указателя). +import os,socket,struct,sys,time,json,glob +N,RUN,IN,ST,TP=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4],int(sys.argv[5]) +def status(**k): + k.setdefault('at',int(time.time())) + t=ST+'.tmp'; open(t,'w').write(json.dumps(k,ensure_ascii=False)); os.chmod(t,0o644); os.replace(t,ST) +pad=lambda b:b+b'\0'*((4-len(b)%4)%4) +a=open(RUN+'/xauth','rb').read(); i=2 +def fld(): + global i + l=struct.unpack('>H',a[i:i+2])[0]; v=a[i+2:i+2+l]; i+=2+l; return v +fld(); fld(); name=fld(); cookie=fld() +s=socket.socket(socket.AF_UNIX); s.connect('/tmp/.X11-unix/X'+N) +s.sendall(struct.pack('<': + hd=struct.unpack(e+'25I',b[:100]) + if hd[1]==7: break + else: return None + hs,w,hgt,order,bpp,bpl,ncol=hd[0],hd[4],hd[5],hd[7],hd[11],hd[12],hd[19] + if bpp!=32: return None + off=hs+ncol*12; px=b[off:off+bpl*hgt] + def rgb(x,y): + p=px[y*bpl+x*4:y*bpl+x*4+4] + return (p[2],p[1],p[0]) if order==0 else (p[1],p[2],p[3]) + return w,hgt,rgb +blue=lambda c:c[2]>c[0]+60 and c[2]>150 +box=None; bar=None # окно (x0,y0,x1,y1); полоса (строка, длина) +def find_box(): + global box + f=frame() + if not f: return + w,hgt,rgb=f + pts=[(x,y) for y in range(0,hgt,8) for x in range(0,w,8) if rgb(x,y)!=(0,0,0)] + if pts: box=(min(p[0] for p in pts),min(p[1] for p in pts),min(w-1,max(p[0] for p in pts)+7),min(hgt-1,max(p[1] for p in pts)+7)) +def progress(): + global bar + f=frame() + if not f or not box: return None + w,hgt,rgb=f; x0,y0,x1,y1=box + if bar is None: # полоса — самая длинная горизонтальная линия в окне (ищется один раз) + bg=rgb(x0+2,y0+60); best=(0,0) + for y in range(y0,y1+1): + run=mx=0 + for x in range(x0,x1+1): + c=rgb(x,y) + if c!=bg and c!=(0,0,0): run+=1; mx=max(mx,run) + else: run=0 + if mx>best[0]: best=(mx,y) + if best[0]<100: return None + bar=(best[1],best[0]) + y,L=bar + n=max(sum(1 for x in range(x0,x1+1) if blue(rgb(x,yy))) for yy in range(max(y0,y-3),min(y1,y+3)+1)) + return min(1.0,n/L) +def alive(): + try: return open('/proc/%d/stat'%TP).read().rsplit(')',1)[1].split()[0]!='Z' + except OSError: return False +for _ in range(200): # окно Валидаты появляется не сразу + find_box() + if box or not alive(): break + time.sleep(0.1) +start=time.time(); moves=0; prog=0.0; last=0 +status(state='running',progress=0,moves=0,window=bool(box),started=int(start)) +while alive() and time.time()-start<840: + if not box: find_box() + for fn in sorted(glob.glob(IN+'/*.mv')): + try: lines=open(fn).read().split('\n') + except OSError: lines=[] + try: os.remove(fn) + except OSError: pass + for l in lines[:2000]: + p=l.split() + if len(p)!=3 or not box: continue + try: x,y,dt=int(p[0]),int(p[1]),int(p[2]) + except ValueError: continue + x0,y0,x1,y1=box + if dt>0: time.sleep(min(dt,60)/1000) # ритм движений человека + move(x0+8+max(0,min(1000,x))*(x1-x0-16)//1000, y0+8+max(0,min(1000,y))*(y1-y0-16)//1000) + moves+=1 + if time.time()-last>0.4: + q=progress() + if q is not None: prog=max(prog,q) + status(state='running',progress=round(prog,3),moves=moves,window=bool(box),started=int(start)); last=time.time() + time.sleep(0.05) +PY +# итог: окно закрылось — датчик набрал случайность (или сеанс истёк/отменён) +for _ in $(seq 1 30); do kill -0 "$TP" 2>/dev/null || break; sleep 0.5; done +kill -0 "$TP" 2>/dev/null && { kill "$TP" 2>/dev/null; st "{\"state\":\"failed\",\"error\":\"время сеанса истекло — датчик не набрал случайность, начните заново\",\"at\":$(date +%s)}"; exit 1; } +TP="" +if rng_ready; then st "{\"state\":\"done\",\"progress\":1,\"at\":$(date +%s)}"; exit 0; fi +E=$(grep -E 'result' "$RUN/testcsp.log" 2>/dev/null | tail -1 | sed 's/.*result: *//' | tr -d '"\\' | head -c 200) +st "{\"state\":\"failed\",\"error\":\"датчик не инициализирован: ${E:-${RNG_ERR:-окно Валидаты закрылось}}\",\"at\":$(date +%s)}"; exit 1 diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index eab2644..ff2fbe6 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -136,6 +136,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service // Пошаговый мастер установки ключа Валидаты на флешку. h.registerKeyWizard(mux) + h.registerValidataRNG(mux) } // renderNews — GET /admin/news. diff --git a/internal/lkgateway/validatarng.go b/internal/lkgateway/validatarng.go new file mode 100644 index 0000000..4ff75d2 --- /dev/null +++ b/internal/lkgateway/validatarng.go @@ -0,0 +1,198 @@ +package lkgateway + +// Датчик случайных чисел СКЗИ Валидата (06.10.2026). Перед первой работой с ключом Валидата собирает случайность +// от движений мыши человека («Пожалуйста, двигайте мышью…»); дальше состояние датчика хранит служба ключей, и +// это делается один раз для сервера. На сервере без экрана окна никто не видит, и всё, что работает с ключом +// в «тихом» режиме — ИШ, пересборка справочника, проверка подписи, — падает с 0x80090022, а ИШ пишет «Не найден +// ключ ЭП (E0700009)», хотя флешка с ключом на месте (так было на чистом сервере .29). +// +// Окно Валидаты открывает root-сеанс bj-validata-rng на виртуальном экране; человек водит мышью по полю в мастере, +// браузер шлёт движения пачками сюда, мы кладём их в /var/lib/bj/rng/in — сеанс повторяет их в окне Валидаты и +// пишет прогресс по полосе самого окна в /var/lib/bj/rng/status.json. Случайность не подменяем ничем своим. + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var rngDir = "/var/lib/bj/rng" + +// rngReady — датчик уже инициализирован (запоминаем: обратно в «не готов» он сам не возвращается). +var rngReady struct { + sync.Mutex + ok bool +} + +// rngView — ответ страницы: готов ли датчик и как идёт сеанс. +type rngView struct { + OK bool `json:"ok"` + Ready bool `json:"ready"` + State string `json:"state,omitempty"` // starting | running | done | failed | cancelled + Progress float64 `json:"progress"` + Window bool `json:"window,omitempty"` // окно Валидаты открылось — движения уже идут в него + Error string `json:"error,omitempty"` +} + +// rngSession — что пишет сеанс в status.json (at — время записи, unix). +type rngSession struct { + State string `json:"state"` + Progress float64 `json:"progress"` + Window bool `json:"window"` + Error string `json:"error"` + At int64 `json:"at"` +} + +func rngReadSession() (rngSession, time.Duration) { + var s rngSession + b, err := os.ReadFile(filepath.Join(rngDir, "status.json")) + if err != nil || json.Unmarshal(b, &s) != nil { + return rngSession{}, 0 + } + return s, time.Since(time.Unix(s.At, 0)) +} + +// rngLive — сеанс идёт (сеанс обновляет файл раз в полсекунды; «starting» пишется до запуска службы). +func rngLive(s rngSession, age time.Duration) bool { + return (s.State == "running" && age < 15*time.Second) || (s.State == "starting" && age < 60*time.Second) +} + +// rngCheck — состояние датчика: идущий сеанс — из его файла, иначе тихая проверка у root-воркера. +func rngCheck(ctx context.Context) rngView { + s, age := rngReadSession() + if rngLive(s, age) { + return rngView{OK: true, State: s.State, Progress: s.Progress, Window: s.Window} + } + rngReady.Lock() + ready := rngReady.ok + rngReady.Unlock() + if !ready { + res, err := runKeymediaOp(ctx, "rng-status", nil) + if err != nil { + return rngView{Error: "проверка датчика не удалась: " + errStr(err)} + } + ready = res["ready"] == true + if ready { + rngReady.Lock() + rngReady.ok = true + rngReady.Unlock() + } + } + v := rngView{OK: true, Ready: ready} + if age < 10*time.Minute { // итог недавнего сеанса — показать, чем кончился + switch { + case ready && s.State == "done": + v.State, v.Progress = "done", 1 + case !ready && (s.State == "failed" || s.State == "cancelled"): + v.State, v.Error = s.State, s.Error + } + } + return v +} + +func (h *setupHandlers) registerValidataRNG(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/rng/status", h.rngStatus) + mux.HandleFunc("/admin/setup/rng/start", h.rngStart) + mux.HandleFunc("/admin/setup/rng/moves", h.rngMoves) + mux.HandleFunc("/admin/setup/rng/cancel", h.rngCancel) +} + +func rngJSON(w http.ResponseWriter, v rngView) { rngJSONCode(w, http.StatusOK, v) } + +func rngJSONCode(w http.ResponseWriter, code int, v rngView) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// GET /admin/setup/rng/status +func (h *setupHandlers) rngStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + rngJSON(w, rngCheck(ctx)) +} + +// POST /admin/setup/rng/start — открыть окно датчика Валидаты (уже готов — сразу ready). +func (h *setupHandlers) rngStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) + defer cancel() + res, err := runKeymediaOp(ctx, "rng-start", nil) + if err != nil { + rngJSON(w, rngView{Error: "окно датчика не открылось: " + errStr(err)}) + return + } + if res["ready"] == true { + rngReady.Lock() + rngReady.ok = true + rngReady.Unlock() + rngJSON(w, rngView{OK: true, Ready: true}) + return + } + rngJSON(w, rngView{OK: true, State: "starting"}) +} + +// rngMovesMax — движений в одной пачке (браузер шлёт раз в четверть секунды, мышь — до 1000 событий в секунду). +const rngMovesMax = 1500 + +// POST /admin/setup/rng/moves — пачка движений мыши из поля мастера: {"m":[[x,y,dt],…]}, x и y — 0…1000 +// по полю, dt — миллисекунды от предыдущего движения. Кладём файлом сеансу; в ответ — прогресс. +func (h *setupHandlers) rngMoves(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s, age := rngReadSession() + if !rngLive(s, age) { + rngJSONCode(w, http.StatusConflict, rngCheck(r.Context())) // сеанса нет: браузер покажет, что дальше + return + } + var in struct { + M [][3]int `json:"m"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { + http.Error(w, "движения: "+err.Error(), http.StatusBadRequest) + return + } + if len(in.M) > rngMovesMax { + in.M = in.M[:rngMovesMax] + } + var b strings.Builder + for _, m := range in.M { + x, y, dt := m[0], m[1], m[2] + if x < 0 || x > 1000 || y < 0 || y > 1000 || dt < 0 || dt > 1000 { + continue + } + fmt.Fprintf(&b, "%d %d %d\n", x, y, dt) + } + if b.Len() > 0 { + dir := filepath.Join(rngDir, "in") + name := filepath.Join(dir, fmt.Sprintf("%019d.mv", time.Now().UnixNano())) + if err := os.WriteFile(name+".tmp", []byte(b.String()), 0o600); err == nil { + _ = os.Rename(name+".tmp", name) + } + } + rngJSON(w, rngView{OK: true, State: s.State, Progress: s.Progress, Window: s.Window}) +} + +// POST /admin/setup/rng/cancel +func (h *setupHandlers) rngCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + _, _ = runKeymediaOp(ctx, "rng-cancel", nil) + rngJSON(w, rngCheck(ctx)) +} diff --git a/internal/lkgateway/validatarng_test.go b/internal/lkgateway/validatarng_test.go new file mode 100644 index 0000000..fd5deb3 --- /dev/null +++ b/internal/lkgateway/validatarng_test.go @@ -0,0 +1,54 @@ +package lkgateway + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Движения из поля мастера доходят до сеанса датчика файлом — только допустимые, и только пока сеанс идёт. +func TestRNGMoves(t *testing.T) { + old := rngDir + rngDir = t.TempDir() + defer func() { rngDir = old }() + rngReady.Lock() + rngReady.ok = true // без обращения к root-воркеру + rngReady.Unlock() + defer func() { rngReady.Lock(); rngReady.ok = false; rngReady.Unlock() }() + _ = os.MkdirAll(filepath.Join(rngDir, "in"), 0o700) + h := &setupHandlers{} + post := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + h.rngMoves(w, httptest.NewRequest(http.MethodPost, "/admin/setup/rng/moves", strings.NewReader(`{"m":[[10,20,0],[1001,5,3],[500,500,16],[-1,0,0]]}`))) + return w + } + if w := post(); w.Code != http.StatusConflict { + t.Fatalf("без сеанса движения не принимаются: %d", w.Code) + } + st := fmt.Sprintf(`{"state":"running","progress":0.42,"window":true,"at":%d}`, time.Now().Unix()) + _ = os.WriteFile(filepath.Join(rngDir, "status.json"), []byte(st), 0o644) + w := post() + var v rngView + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil || w.Code != 200 || v.Progress != 0.42 || !v.Window { + t.Fatalf("ответ: %d %s", w.Code, w.Body.String()) + } + files, _ := filepath.Glob(filepath.Join(rngDir, "in", "*.mv")) + if len(files) != 1 { + t.Fatalf("файлов движений: %d", len(files)) + } + b, _ := os.ReadFile(files[0]) + if string(b) != "10 20 0\n500 500 16\n" { + t.Fatalf("движения: %q", b) + } + // сеанс кончился (файл давно не обновлялся) — датчик готов, итог «done» показывается + _ = os.WriteFile(filepath.Join(rngDir, "status.json"), []byte(fmt.Sprintf(`{"state":"done","progress":1,"at":%d}`, time.Now().Unix())), 0o644) + if v := rngCheck(t.Context()); !v.Ready || v.State != "done" { + t.Fatalf("после сеанса: %+v", v) + } +} diff --git a/internal/lkgateway/web/templates/part_keywiz.html b/internal/lkgateway/web/templates/part_keywiz.html index 5dc71cd..97a7878 100644 --- a/internal/lkgateway/web/templates/part_keywiz.html +++ b/internal/lkgateway/web/templates/part_keywiz.html @@ -14,8 +14,73 @@ .kw-tile small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:3px} .kw-panel{margin-top:16px} .kw-drive{display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;cursor:pointer;margin:0} +.kw-pad{height:200px;margin-top:12px;border:2px dashed var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;text-align:center;padding:0 16px;color:var(--muted);user-select:none;-webkit-user-select:none;touch-action:none;cursor:default} +.kw-pad.on{border-color:var(--accent);background:var(--accent-weak);color:var(--accent);cursor:crosshair} +.kw-bar{height:8px;margin-top:12px;border-radius:4px;background:var(--surface-2,#eee);overflow:hidden} +.kw-bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .3s} +.kw-rng.ok .kw-bar i{background:var(--ok)} +.kw-rng-row{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:10px} +.kw-locked{opacity:.45;pointer-events:none;filter:grayscale(.3)} @media (max-width:600px){.kw-tiles{grid-template-columns:minmax(0,1fr)}} +{{/* ===== Датчик случайных чисел Валидаты: один раз для сервера, до первой работы с ключом (validatarng.go) ===== */}} +{{if not $s.Done}} + + +{{end}} +
{{/* ===== Лента шагов — когда путь выбран (флешка с ключом или новая флешка из архива) ===== */}} {{if or $s.StagingID $s.Done}}
@@ -174,4 +239,5 @@ r.forEach(function(x){ x.addEventListener('change', sync); }); sync(); })(); {{end}} +
{{end}}