Ключ: датчик случайных чисел Валидаты инициализируется из мастера — человек водит мышью в браузере
На сервере без экрана окно Валидаты «Пожалуйста, двигайте мышью» никто не видел, ключ в тихом режиме падал с 0x80090022 (ИШ: «Не найден ключ ЭП (E0700009)»), пересборка справочника — тоже. bj-validata-rng: тихая проверка датчика (testcsp -silent -random), сеанс на виртуальном экране, движения из поля мастера повторяются в окне Валидаты (XTEST), прогресс — по полосе её окна. Установщик ключа проверяет датчик до записи на флешку. zpki.ini: Count = числу профилей, блоки подряд. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+220
@@ -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('<BxHHHH2x',ord('l'),11,0,len(name),len(cookie))+pad(name)+pad(cookie))
|
||||
def rd(n):
|
||||
b=b''
|
||||
while len(b)<n:
|
||||
c=s.recv(n-len(b))
|
||||
if not c: raise SystemExit('X: соединение закрыто')
|
||||
b+=c
|
||||
return b
|
||||
h=rd(8)
|
||||
if h[0]!=1: raise SystemExit('X: отказ в подключении')
|
||||
body=rd(struct.unpack('<H',h[6:8])[0]*4)
|
||||
vlen,nfmt=struct.unpack('<H',body[16:18])[0],body[21]
|
||||
root=struct.unpack('<I',body[32+((vlen+3)//4)*4+8*nfmt:][:4])[0]
|
||||
s.sendall(struct.pack('<BxHHxx',98,2+(len(b'XTEST')+3)//4,len(b'XTEST'))+pad(b'XTEST'))
|
||||
r=rd(32)
|
||||
if r[0]!=1 or not r[8]: raise SystemExit('X: нет расширения XTEST')
|
||||
xt=r[9]; s.setblocking(False)
|
||||
def move(x,y):
|
||||
s.setblocking(True); s.sendall(struct.pack('<BBHBBHIIIIhhIHBB',xt,2,9,6,0,0,0,root,0,0,x,y,0,0,0,0)); s.setblocking(False)
|
||||
try:
|
||||
while s.recv(4096): pass # ошибки/события не копим
|
||||
except BlockingIOError: pass
|
||||
# кадр экрана Xvfb (XWD): окно — непустая область, полоса прогресса — самая длинная горизонтальная линия окна
|
||||
def frame():
|
||||
try: b=open(RUN+'/fb/Xvfb_screen0','rb').read()
|
||||
except OSError: return None
|
||||
for e in '><':
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)}}
|
||||
</style>
|
||||
{{/* ===== Датчик случайных чисел Валидаты: один раз для сервера, до первой работы с ключом (validatarng.go) ===== */}}
|
||||
{{if not $s.Done}}
|
||||
<div class="card kw-rng" id="kw-rng" hidden>
|
||||
<h2>Датчик случайных чисел Валидаты</h2>
|
||||
<p class="muted" style="margin:0">Перед первой работой с ключом СКЗИ Валидата набирает случайность от движений мыши — на рабочем месте она показывает окно «Пожалуйста, двигайте мышью». У сервера экрана нет: окно Валидаты открыто на сервере, а мышью водите вы — здесь, bj передаёт ваши движения в это окно. Делается один раз для сервера, около минуты.</p>
|
||||
<div class="kw-pad" id="kw-pad">Нажмите «Начать» и водите мышью по этому полю, пока полоса не заполнится</div>
|
||||
<div class="kw-bar"><i id="kw-rng-bar"></i></div>
|
||||
<div class="kw-rng-row"><span id="kw-rng-msg" class="muted">Датчик ещё не инициализирован — без этого ключ на сервере не работает.</span>
|
||||
<span style="display:flex;gap:8px"><button type="button" class="btn btn-secondary" id="kw-rng-stop" hidden>Остановить</button><button type="button" class="btn btn-ok" id="kw-rng-go">Начать</button></span></div>
|
||||
</div>
|
||||
<script>(function(){
|
||||
var card=document.getElementById('kw-rng'), pad=document.getElementById('kw-pad'),
|
||||
bar=document.getElementById('kw-rng-bar'), msg=document.getElementById('kw-rng-msg'), go=document.getElementById('kw-rng-go'), stop=document.getElementById('kw-rng-stop');
|
||||
var active=false, buf=[], lastT=0, sending=false, lastSent=0, shown=false, finished=false;
|
||||
var J={'Accept':'application/json'};
|
||||
function lock(on){ var m=document.getElementById('kw-main'); if(m) m.classList.toggle('kw-locked', on); }
|
||||
function pct(v){ return Math.round(Math.min(1,v||0)*100); }
|
||||
function show(v){
|
||||
if(finished) return;
|
||||
if(v.ready){ lock(false); active=false;
|
||||
if(shown||v.state==='done'){ card.hidden=false; card.classList.add('ok'); finished=true; bar.style.width='100%'; pad.hidden=true; go.hidden=true; stop.hidden=true;
|
||||
msg.textContent='✓ Датчик готов — теперь подключите ключ ниже. Повторять это не нужно.'; }
|
||||
return; }
|
||||
shown=true; card.hidden=false; lock(true);
|
||||
if(v.state==='running'||v.state==='starting'){
|
||||
active=true; pad.classList.add('on'); go.hidden=true; stop.hidden=false; bar.style.width=pct(v.progress)+'%';
|
||||
pad.textContent=v.window?'Водите мышью по полю — '+pct(v.progress)+' %':'Открываю окно Валидаты на сервере…';
|
||||
msg.textContent=v.window?'Ведите мышью без остановки, пока полоса не заполнится.':'Ещё пара секунд…';
|
||||
} else {
|
||||
active=false; pad.classList.remove('on'); go.hidden=false; stop.hidden=true; bar.style.width='0';
|
||||
pad.textContent='Нажмите «Начать» и водите мышью по этому полю, пока полоса не заполнится';
|
||||
go.textContent=(v.state==='failed'||v.state==='cancelled')?'Начать заново':'Начать';
|
||||
msg.textContent=v.error?('Не получилось: '+v.error):(v.ok===false?'Состояние датчика не проверено':'Датчик ещё не инициализирован — без этого ключ на сервере не работает.');
|
||||
}
|
||||
}
|
||||
function status(){ return fetch('/admin/setup/rng/status',{headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){}); }
|
||||
go.addEventListener('click',function(){ go.disabled=true; msg.textContent='Открываю окно Валидаты на сервере…';
|
||||
fetch('/admin/setup/rng/start',{method:'POST',headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show)
|
||||
.catch(function(){ msg.textContent='Сервер не ответил — попробуйте ещё раз'; }).then(function(){ go.disabled=false; }); });
|
||||
stop.addEventListener('click',function(){ active=false; buf=[];
|
||||
fetch('/admin/setup/rng/cancel',{method:'POST',headers:J,cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){}); });
|
||||
pad.addEventListener('pointermove',function(e){ if(!active) return;
|
||||
var r=pad.getBoundingClientRect(), list=(e.getCoalescedEvents&&e.getCoalescedEvents().length)?e.getCoalescedEvents():[e];
|
||||
list.forEach(function(ev){ var x=Math.round((ev.clientX-r.left)/r.width*1000), y=Math.round((ev.clientY-r.top)/r.height*1000), t=ev.timeStamp;
|
||||
var dt=lastT?Math.round(t-lastT):0; lastT=t;
|
||||
buf.push([Math.max(0,Math.min(1000,x)),Math.max(0,Math.min(1000,y)),Math.max(0,Math.min(1000,dt))]); });
|
||||
});
|
||||
setInterval(function(){
|
||||
if(!active||sending) return;
|
||||
if(buf.length){ var m=buf.splice(0,1500); sending=true; lastSent=Date.now();
|
||||
fetch('/admin/setup/rng/moves',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/json'},body:JSON.stringify({m:m}),cache:'no-store'})
|
||||
.then(function(r){return r.json()}).then(show).catch(function(){}).then(function(){ sending=false; });
|
||||
} else if(Date.now()-lastSent>1000){ sending=true; lastSent=Date.now(); status().then(function(){ sending=false; }); }
|
||||
},250);
|
||||
document.addEventListener('DOMContentLoaded',status); if(document.readyState!=='loading') status();
|
||||
})();</script>
|
||||
{{end}}
|
||||
<div id="kw-main">
|
||||
{{/* ===== Лента шагов — когда путь выбран (флешка с ключом или новая флешка из архива) ===== */}}
|
||||
{{if or $s.StagingID $s.Done}}
|
||||
<div class="card">
|
||||
@@ -174,4 +239,5 @@
|
||||
r.forEach(function(x){ x.addEventListener('change', sync); }); sync();
|
||||
})();</script>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user