#!/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
