#!/usr/bin/env bash
# INSTALADOR OFICIAL: FRAMEWORK / CLI BISSAR-OCR
set +axv
set -euo pipefail
umask 077
OCR_INSTALLER_VERSION=1.4.0

fail() { printf '%s\n' "$1" >&2; exit 2; }
usage() {
    cat <<'HELP'
BISSAR-OCR: instalación o actualización con verificación de la licencia existente.
  install.sh [--url https://bissar.co] [--key CLAVE | --key-file ARCHIVO] [--reactivar] [--service]
  --key-file  Lee una clave desde un archivo privado; evita incluirla en argv.
  --service   Instala y activa el puente local mediante systemd con usuario dinámico.
  --reactivar Solicita una clave y reemplaza la configuración solo si la activación tiene éxito.
  --user-password-file ARCHIVO  Usa otra contraseña inicial para bissar desde un archivo privado.
  --ssh-key-file ARCHIVO       Clave pública OpenSSH o PEM; crea bissar sin contraseña si no se indica otra.
  --ssh-auto  Genera y conserva un PEM privado por servidor; autoriza SSH sin contraseña.
  --ssh-remote root@SERVIDOR  Desde su equipo: instala con --ssh-auto, recupera el PEM y comprueba SSH.
  --install-config ARCHIVO.json  Configuración de SSH sin contraseña, generada en el panel.
  --version   Muestra la versión del instalador.
  --help      Muestra esta ayuda.
Se solicita la clave por /dev/tty si falta una activación válida o se usa --reactivar.
Con root/sudo se crea la cuenta local bissar con la contraseña inicial configurada,
sin solicitarla, salvo el modo SSH sin contraseña. Si ya existe, se conserva.
La instalación personal no crea cuentas.
HELP
}

OCR_BASE_URL=https://bissar.co
OCR_KEY=''
OCR_KEY_FILE=''
OCR_SERVICE=0
OCR_REACTIVATE=0
OCR_USER_PASSWORD_FILE=''
unset OCR_USER_PASSWORD
OCR_USER_PASSWORD=''
OCR_CHPASSWD_ARGS=()
# Contraseña inicial compartida solicitada por el administrador. Se conserva
# únicamente el hash; --user-password-file permite elegir otra en cada equipo.
OCR_DEFAULT_USER_PASSWORD_HASH='$6$rounds=200000$o9xSwBvDHDtjFOzM$x7dddgCTVa/BcNUiKinRYJXh89zZxnkyLuI5B3HB7ioHKP3ybOAL6cHIk0.J1ChIwA.R4.CLxh7eqn2j6dhne0'
OCR_CREATE_USER=0
OCR_USER_HOME=/home/bissar
OCR_SSH_KEY_FILE=''
OCR_SSH_AUTO=0
OCR_SSH_REMOTE=''
OCR_SSH_IDENTITY_DIR=/etc/bissar-ocr/ssh-access
OCR_SSH_CONFIG=/etc/ssh/sshd_config
OCR_SSH_KEYS=/etc/ssh/bissar-cli/authorized_keys
OCR_SSH_SERVICE=''
OCR_INSTALL_CONFIG=''
OCR_PASSWORDLESS=0
OCR_USERADD_ARGS=()
while (($#)); do
    case "$1" in
        --key|--clave) (($# >= 2)) || fail 'Falta valor de --key.'; OCR_KEY=$2; shift 2 ;;
        --key-file) (($# >= 2)) || fail 'Falta valor de --key-file.'; OCR_KEY_FILE=$2; shift 2 ;;
        --url) (($# >= 2)) || fail 'Falta valor de --url.'; OCR_BASE_URL=${2%/}; shift 2 ;;
        --service|--daemon) OCR_SERVICE=1; shift ;;
        --reactivar) OCR_REACTIVATE=1; shift ;;
        --user-password-file) (($# >= 2)) || fail 'Falta valor de --user-password-file.'; OCR_USER_PASSWORD_FILE=$2; [[ -n "$OCR_USER_PASSWORD_FILE" ]] || fail 'Archivo de contraseña vacío.'; shift 2 ;;
        --ssh-key-file) (($# >= 2)) || fail 'Falta valor de --ssh-key-file.'; OCR_SSH_KEY_FILE=$2; [[ -n "$OCR_SSH_KEY_FILE" ]] || fail 'Archivo de clave pública vacío.'; shift 2 ;;
        --ssh-auto) OCR_SSH_AUTO=1; shift ;;
        --ssh-remote) (($# >= 2)) || fail 'Falta valor de --ssh-remote.'; OCR_SSH_REMOTE=$2; [[ -n "$OCR_SSH_REMOTE" ]] || fail 'Servidor SSH vacío.'; shift 2 ;;
        --install-config) (($# >= 2)) || fail 'Falta valor de --install-config.'; OCR_INSTALL_CONFIG=$2; [[ -n "$OCR_INSTALL_CONFIG" ]] || fail 'Archivo de configuración vacío.'; shift 2 ;;
        --version) printf 'Instalador BISSAR-OCR %s\n' "$OCR_INSTALLER_VERSION"; exit 0 ;;
        --help|-h) usage; exit 0 ;;
        *) fail 'Opción desconocida. Use --help.' ;;
    esac
done
command -v python3 >/dev/null || fail 'Se requiere Python 3.8 o posterior.'
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,8) else 1)' || fail 'Se requiere Python 3.8 o posterior.'
python3 - "$OCR_BASE_URL" <<'PY'
import sys, urllib.parse
p=urllib.parse.urlsplit(sys.argv[1])
try:
    p.port
    assert p.scheme=='https' and p.hostname and not (p.username or p.password or p.query or p.fragment)
    assert not any(ord(c)<=32 or ord(c)==127 for c in sys.argv[1])
except (AssertionError,ValueError):
    sys.exit('Se requiere una URL HTTPS válida, sin usuario ni contraseña.')
PY
[[ -z "$OCR_KEY" || -z "$OCR_KEY_FILE" ]] || fail 'Use --key o --key-file, no ambos.'
if (( OCR_SSH_AUTO )) || [[ -n "$OCR_SSH_REMOTE" ]]; then
    [[ -z "$OCR_SSH_KEY_FILE" && -z "$OCR_INSTALL_CONFIG" && -z "$OCR_USER_PASSWORD_FILE" ]] || fail '--ssh-auto y --ssh-remote no se combinan con claves SSH externas, --install-config ni --user-password-file.'
fi
if [[ -n "$OCR_SSH_REMOTE" ]]; then
    [[ -z "$OCR_KEY" ]] || fail 'Para la licencia remota use --key-file o la solicitud de la terminal, no --key.'
    command -v ssh >/dev/null && command -v ssh-keygen >/dev/null || fail 'Se requiere el cliente OpenSSH en su equipo.'
    [[ -f ${BASH_SOURCE[0]:-} ]] || fail 'Descargue install.sh antes de usar --ssh-remote.'
    python3 - "$OCR_SSH_REMOTE" "${BASH_SOURCE[0]}" "$OCR_BASE_URL" "$OCR_KEY_FILE" "$OCR_REACTIVATE" "$OCR_SERVICE" "$OCR_SSH_IDENTITY_DIR" <<'PY'
import hashlib,os,pathlib,re,shlex,stat,subprocess,sys,tempfile

target,script,url,license_file,reactivate,service,remote_identity=sys.argv[1:]
if not re.fullmatch(r'root@[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}',target):
    sys.exit('Use --ssh-remote root@IP, root@nombre o root@alias de su configuración SSH.')
host=target[5:]
remote=None
def private_dir(path):
    if not path.exists():path.mkdir(mode=0o700)
    info=path.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.geteuid() or info.st_mode & 0o077:
        raise ValueError('Se requiere un directorio propio y privado, sin enlaces: '+str(path))
def private_file(path):
    fd=os.open(str(path),os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd,'rb') as f:
        info=os.fstat(f.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.geteuid() or info.st_mode & 0o077 or info.st_size>16384:
            raise ValueError('Archivo privado inválido: '+str(path))
        return f.read(16385)
def run(args,**kwargs):
    result=subprocess.run(args,**kwargs)
    if result.returncode:raise ValueError('Falló una operación SSH. Se conservan las claves existentes para reintentar.')
    return result
try:
    # Validar la licencia local antes de establecer conexiones; nunca va en argv.
    license_data=private_file(pathlib.Path(license_file)) if license_file else None
    if license_data is not None and (not license_data.strip() or len(license_data)>256):
        raise ValueError('La licencia requiere un archivo privado de hasta 256 bytes.')
    base=pathlib.Path.home()/'.ssh'
    private_dir(base)
    base=base/'bissar-ocr';private_dir(base)
    folder=base/hashlib.sha256(target.encode()).hexdigest()[:20];private_dir(folder)
    identity=folder/'bissar-access.pem'
    if identity.exists() or identity.is_symlink():private_file(identity)
    with tempfile.TemporaryDirectory(prefix='bissar-ssh-') as temporary:
        socket=str(pathlib.Path(temporary)/'control')
        transport=['ssh','-S',socket,'-o','ControlMaster=auto','-o','ControlPersist=60',
                   '-o','StrictHostKeyChecking=ask','-o','ConnectTimeout=15',
                   '-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3']
        try:
            print('Conectando con el acceso administrativo existente a '+target+'...',flush=True)
            result=run(transport+['-T',target,'umask 077; mktemp -d /tmp/bissar-install.XXXXXXXX'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,timeout=60)
            remote=result.stdout.decode().strip()
            if not re.fullmatch(r'/tmp/bissar-install\.[a-zA-Z0-9]{8}',remote):
                remote=None;raise ValueError('El servidor no devolvió un directorio temporal válido.')
            run(transport+['-T',target,'umask 077; cat > '+shlex.quote(remote+'/install.sh')],input=pathlib.Path(script).read_bytes(),timeout=60)
            flags=['--url',url,'--ssh-auto']
            if license_data is not None:
                run(transport+['-T',target,'umask 077; cat > '+shlex.quote(remote+'/license')],input=license_data,timeout=30)
                flags+=['--key-file',remote+'/license']
            if reactivate=='1':flags+=['--reactivar']
            if service=='1':flags+=['--service']
            command='bash '+shlex.quote(remote+'/install.sh')+' '+ ' '.join(map(shlex.quote,flags))
            try:terminal=open('/dev/tty','rb+',buffering=0)
            except OSError:terminal=None
            try:run(transport+(['-tt'] if terminal else ['-T'])+[target,command],stdin=terminal or subprocess.DEVNULL)
            finally:
                if terminal:terminal.close()
            data=run(transport+['-T',target,'cat -- '+shlex.quote(remote_identity+'/bissar-access.pem')],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,timeout=30).stdout
            if len(data)>16384 or not data.startswith(b'-----BEGIN RSA PRIVATE KEY-----\n'):
                raise ValueError('No se recibió un PEM privado válido. Se conserva la copia del servidor.')
            fd,pending=tempfile.mkstemp(prefix='.pem-',dir=str(folder))
            try:
                with os.fdopen(fd,'wb') as f:
                    os.fchmod(f.fileno(),0o600);f.write(data);f.flush();os.fsync(f.fileno())
                run(['ssh-keygen','-y','-P','','-f',pending],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=10)
                if identity.exists() or identity.is_symlink():
                    if private_file(identity)!=data:raise ValueError('El servidor tiene una clave distinta. Se conserva el PEM local; revise el cambio antes de reemplazarlo: '+str(identity))
                else:os.link(pending,identity)
            finally:os.unlink(pending)
            print('PEM copiado automáticamente a '+str(identity)+' (0600).',flush=True)
            # Usar el puerto efectivo del mismo alias, incluso con Match User root.
            settings=run(['ssh','-G',target],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=10).stdout.decode()
            port=next(line.split()[1] for line in settings.splitlines() if line.startswith('port '))
            if not port.isdigit() or not 1<=int(port)<=65535:raise ValueError('Puerto SSH inválido.')
            connect=['ssh','-p',port,'-o','IdentitiesOnly=yes','-o','IdentityAgent=none','-i',str(identity),'bissar@'+host]
            result=run(connect[:-1]+['-o','ControlMaster=no','-o','ControlPath=none','-o','BatchMode=yes',
                       '-o','StrictHostKeyChecking=yes','-o','PreferredAuthentications=publickey',
                       '-o','ConnectTimeout=15','-T',connect[-1],'id -un'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,timeout=30)
            if result.stdout.strip()!=b'bissar':raise ValueError('La conexión no confirmó el usuario bissar.')
            print('Conexión SSH automática verificada como bissar. Para abrir una sesión:')
            print(' '.join(map(shlex.quote,connect)))
        finally:
            if remote:
                subprocess.run(transport+['-o','BatchMode=yes','-T',target,'rm -rf -- '+shlex.quote(remote)],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
            subprocess.run(['ssh','-S',socket,'-O','exit',target],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
except (OSError,ValueError,StopIteration,subprocess.SubprocessError) as exc:
    sys.exit('No se completó la instalación o conexión remota: '+str(exc)+'. El PEM generado se conserva en '+remote_identity+'/bissar-access.pem; las copias locales existentes no se reemplazan.')
PY
    exit $?
fi
[[ $(uname -s) == Linux ]] || fail 'Se requiere Linux.'
OCR_TEMP=$(mktemp -d)
trap 'rm -rf -- "$OCR_TEMP"' EXIT
if (( OCR_SSH_AUTO )); then OCR_SSH_KEY_FILE="$OCR_TEMP/ssh-auto-public.pem"; fi
if [[ -n "$OCR_INSTALL_CONFIG" ]]; then
    [[ -z "$OCR_SSH_KEY_FILE" && -z "$OCR_USER_PASSWORD_FILE" ]] || fail '--install-config no se combina con --ssh-key-file ni --user-password-file.'
    OCR_SSH_KEY_FILE=$(python3 - "$OCR_INSTALL_CONFIG" "$OCR_TEMP/ssh-input.pem" <<'PY'
import json,os,pathlib,stat,sys
def unique(pairs):
    result={}
    for key,value in pairs:
        if key in result:raise ValueError()
        result[key]=value
    return result
try:
    path=pathlib.Path(sys.argv[1]).absolute()
    fd=os.open(str(path),os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd,'rb') as f:
        info=os.fstat(f.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022 or info.st_size>65536:raise ValueError()
        data=f.read(65537)
    if len(data)>65536:raise ValueError()
    config=json.loads(data,object_pairs_hook=unique)
    if not isinstance(config,dict) or set(config)-{'version','crear_password','ssh_public_key_pem','ssh_public_key_file'}:raise ValueError()
    if type(config.get('version')) is not int or config['version']!=1 or config.get('crear_password') is not False:raise ValueError()
    if ('ssh_public_key_pem' in config)==('ssh_public_key_file' in config):raise ValueError()
    if 'ssh_public_key_pem' in config:
        value=config['ssh_public_key_pem']
        if not isinstance(value,str) or len(value)>16384 or 'PRIVATE KEY' in value or not value.strip().startswith(('-----BEGIN PUBLIC KEY-----','-----BEGIN RSA PUBLIC KEY-----')):raise ValueError()
        source=pathlib.Path(sys.argv[2]);source.write_text(value,encoding='ascii')
    else:
        value=config['ssh_public_key_file']
        if not isinstance(value,str) or not value or any(ord(c)<32 or ord(c)==127 for c in value):raise ValueError()
        source=pathlib.Path(value)
        if not source.is_absolute():source=path.parent/source
    print(str(source))
except (OSError,ValueError,UnicodeError):
    sys.exit('Configuración de instalación inválida. Use el JSON del panel: version 1, crear_password false y una clave pública PEM o su ruta; nunca una clave privada.')
PY
    ) || fail 'No se pudo cargar la configuración adicional.'
    OCR_PASSWORDLESS=1
fi
if [[ -n "$OCR_SSH_KEY_FILE" && -z "$OCR_USER_PASSWORD_FILE" ]]; then
    OCR_PASSWORDLESS=1
    # Marcador sin contraseña utilizable, compatible con acceso por clave pública.
    # No usar un hash vacío ni el bloqueo de cuenta "!" que puede impedir SSH.
    OCR_USERADD_ARGS=(--password '*NP*')
fi
if (( EUID == 0 )); then
    OCR_DEST=/usr/local/bin/bissar-ocr
    OCR_CONFIG=/etc/bissar-ocr/config.json
    command -v getent >/dev/null || fail 'Se requiere getent para comprobar la cuenta bissar.'
    OCR_ACCOUNT_RC=0
    OCR_ACCOUNT=$(getent passwd bissar) || OCR_ACCOUNT_RC=$?
    if (( OCR_ACCOUNT_RC == 0 )); then
        IFS=: read -r OCR_ACCOUNT_NAME OCR_ACCOUNT_MARKER OCR_ACCOUNT_UID OCR_ACCOUNT_REST <<< "$OCR_ACCOUNT"
        [[ "$OCR_ACCOUNT_NAME" == bissar && "$OCR_ACCOUNT_UID" =~ ^[0-9]+$ && "$OCR_ACCOUNT_UID" != 0 ]] || fail 'La cuenta bissar existente no es una cuenta válida sin privilegios de root.'
        printf 'La cuenta local bissar ya existe; se conservan su contraseña y sus permisos.\n'
        [[ -z "$OCR_USER_PASSWORD_FILE" ]] || printf 'Se omite --user-password-file porque la cuenta ya existe. Para cambiar su contraseña use: sudo passwd bissar\n'
    elif (( OCR_ACCOUNT_RC == 2 )); then
        command -v useradd >/dev/null || fail 'Se requiere useradd para crear la cuenta bissar.'
        if (( OCR_PASSWORDLESS == 0 )); then
            command -v chpasswd >/dev/null || fail 'Se requiere chpasswd para establecer la contraseña de bissar.'
        fi
        [[ -x /bin/bash ]] || fail 'Se requiere /bin/bash para la cuenta bissar.'
        [[ ! -e "$OCR_USER_HOME" && ! -L "$OCR_USER_HOME" ]] || fail 'Ya existe el directorio de bissar sin la cuenta. Revíselo antes de instalar; se conserva su contenido.'
        OCR_CREATE_USER=1
    else
        fail 'No se pudo comprobar la cuenta bissar. No se modificarán usuarios.'
    fi
else
    OCR_DEST="$HOME/.local/bin/bissar-ocr"
    OCR_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/bissar-ocr/config.json"
    (( OCR_SERVICE == 0 )) || fail '--service requiere root.'
    [[ -z "$OCR_USER_PASSWORD_FILE" ]] || fail '--user-password-file requiere root/sudo para crear la cuenta bissar.'
    [[ -z "$OCR_SSH_KEY_FILE" ]] || fail '--ssh-key-file requiere root/sudo.'
    printf 'Instalación personal para el usuario actual. Para crear la cuenta bissar, ejecute el instalador con sudo.\n'
fi
[[ ! -L "$OCR_CONFIG" && ! -L "$OCR_DEST" ]] || fail 'No se admiten enlaces simbólicos en los destinos.'
if [[ -n "$OCR_SSH_KEY_FILE" ]]; then
    command -v sshd >/dev/null && command -v ssh-keygen >/dev/null || fail '--ssh-key-file requiere OpenSSH Server instalado.'
    command -v systemctl >/dev/null || fail '--ssh-key-file requiere SSH administrado por systemd.'
    if systemctl is-active --quiet ssh.service; then OCR_SSH_SERVICE=ssh.service
    elif systemctl is-active --quiet sshd.service; then OCR_SSH_SERVICE=sshd.service
    else fail 'SSH debe estar activo antes de autorizar la cuenta bissar.'
    fi
    [[ -f "$OCR_SSH_CONFIG" && ! -L "$OCR_SSH_CONFIG" ]] || fail 'La configuración SSH debe ser un archivo regular sin enlaces.'
    sshd -t -f "$OCR_SSH_CONFIG" || fail 'La configuración SSH actual tiene errores; se conserva sin cambios.'
fi
if (( OCR_SERVICE )); then
    command -v systemctl >/dev/null || fail 'systemd es necesario para --service.'
    OCR_SYSTEMD_VERSION=$(systemctl --version | head -n 1)
    OCR_SYSTEMD_VERSION=${OCR_SYSTEMD_VERSION#systemd }
    OCR_SYSTEMD_VERSION=${OCR_SYSTEMD_VERSION%% *}
    [[ "$OCR_SYSTEMD_VERSION" =~ ^[0-9]+$ ]] && (( OCR_SYSTEMD_VERSION >= 247 )) || fail 'El puente requiere systemd 247+ para LoadCredential.'
fi
OCR_SOURCE=''
if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then
    OCR_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
    [[ ! -f "$OCR_SCRIPT_DIR/bin/bissar-ocr" ]] || OCR_SOURCE="$OCR_SCRIPT_DIR/bin/bissar-ocr"
fi
if [[ -z "$OCR_SOURCE" ]]; then
    command -v curl >/dev/null || fail 'curl es necesario para descargar el cliente.'
    curl --fail --silent --show-error --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 60 \
        "$OCR_BASE_URL/api/v1/bissar-ocr/bin/bissar-ocr" -o "$OCR_TEMP/bissar-ocr"
    OCR_SOURCE="$OCR_TEMP/bissar-ocr"
fi
# Parsear antes de instalar; una página HTML o descarga incompleta no reemplaza el CLI.
python3 - "$OCR_SOURCE" <<'PY'
import ast, pathlib, sys
p=pathlib.Path(sys.argv[1]);text=p.read_text()
assert 1000 < len(text) < 200000 and text.startswith('#!/usr/bin/env python3') and 'BISSAR-OCR' in text
ast.parse(text)
PY
if (( OCR_SSH_AUTO )); then
    python3 - "$OCR_SSH_IDENTITY_DIR" "$OCR_SSH_KEY_FILE" <<'PY'
import fcntl,os,pathlib,shutil,stat,subprocess,sys,tempfile

folder=pathlib.Path(sys.argv[1])
def safe_dir(path,private=False):
    info=path.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid not in (0,os.geteuid()):raise ValueError('Directorio SSH inválido: '+str(path))
    if private and (info.st_uid!=os.geteuid() or info.st_mode & 0o077):raise ValueError('El directorio de claves debe ser propio y privado (0700): '+str(path))
    if info.st_mode & 0o022 and not (info.st_uid==0 and info.st_mode & stat.S_ISVTX):raise ValueError('Directorio modificable por otros: '+str(path))
def private_file(path):
    fd=os.open(str(path),os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    info=os.fstat(fd)
    if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.geteuid() or info.st_mode & 0o077 or info.st_nlink!=1 or info.st_size>16384:
        os.close(fd);raise ValueError('El PEM debe ser un archivo privado propio (0600), sin enlaces: '+str(path))
    return fd
def run(args):
    result=subprocess.run(args,stdin=subprocess.DEVNULL,capture_output=True,timeout=90)
    if result.returncode:raise ValueError('No se pudo generar o comprobar el PEM. Se conserva cualquier clave existente.')
    return result.stdout
def sync_dir():
    fd=os.open(str(folder),os.O_RDONLY | os.O_DIRECTORY)
    try:os.fsync(fd)
    finally:os.close(fd)
try:
    for parent in reversed(folder.parents):
        if not parent.exists():parent.mkdir(mode=0o700)
        safe_dir(parent)
    if not folder.exists():folder.mkdir(mode=0o700)
    safe_dir(folder,True)
    fd=os.open(str(folder/'.lock'),os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,0o600)
    info=os.fstat(fd)
    if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.geteuid() or info.st_mode & 0o077:raise ValueError('Bloqueo del directorio SSH inválido.')
    fcntl.flock(fd,fcntl.LOCK_EX)
    identity=folder/'bissar-access.pem'
    created=False
    if not identity.exists() and not identity.is_symlink():
        # Generar en el mismo filesystem. El PEM publicado no pertenece al
        # temporal del instalador: sobrevive a fallos y se reutiliza al reintentar.
        stage=pathlib.Path(tempfile.mkdtemp(prefix='.generando-',dir=str(folder)))
        try:
            candidate=stage/'access.pem'
            run(['ssh-keygen','-q','-t','rsa','-b','3072','-m','PEM','-N','','-C','BISSAR CLI acceso SSH','-f',str(candidate)])
            check=private_file(candidate);os.fsync(check);os.close(check)
            # link falla si otro proceso creó el destino; nunca sobreescribir.
            os.link(candidate,identity);candidate.unlink();sync_dir();created=True
        finally:shutil.rmtree(stage)
    check=private_file(identity)
    with os.fdopen(check,'rb') as f:
        data=f.read(16385)
    if not data.startswith(b'-----BEGIN RSA PRIVATE KEY-----\n'):raise ValueError('La identidad existente no es el PEM esperado; se conserva sin reemplazar.')
    public=run(['ssh-keygen','-y','-P','','-f',str(identity)])
    public_path=folder/'bissar-access.pub'
    # La pública preparada vive en el temporal; no depender de copias derivadas.
    prepared=pathlib.Path(sys.argv[2]);prepared.write_bytes(public)
    pem=run(['ssh-keygen','-e','-m','PKCS8','-f',str(prepared)])
    prepared.write_bytes(pem)
    print(('PEM privado generado' if created else 'PEM privado conservado')+' en '+str(identity)+' (0600).')
    print('Esta clave permanece disponible si la instalación se interrumpe; vuelva a usar --ssh-auto.')
except (OSError,ValueError,subprocess.SubprocessError) as exc:
    sys.exit('No se pudo preparar el acceso SSH automático: '+str(exc))
PY
fi
if [[ -n "$OCR_SSH_KEY_FILE" ]]; then
    python3 - "$OCR_SSH_KEY_FILE" "$OCR_TEMP/ssh-key.pub" "$OCR_TEMP/ssh-public.pem" <<'PY'
import base64,os,pathlib,re,stat,struct,subprocess,sys
try:
    fd=os.open(sys.argv[1],os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd,'rb') as f:
        info=os.fstat(f.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022 or info.st_size>16384:raise ValueError()
        data=f.read(16385)
    text=data.decode('ascii').strip()
    if len(data)>16384 or 'PRIVATE KEY' in text:raise ValueError()
    pem=text.startswith('-----BEGIN ')
    if pem:
        match=re.fullmatch(r'-----BEGIN (PUBLIC KEY|RSA PUBLIC KEY)-----\s+([A-Za-z0-9+/=\s]+)\s+-----END \1-----',text)
        if not match:raise ValueError()
        source=pathlib.Path(sys.argv[3]);source.write_text(text+'\n')
        converted=subprocess.run(['ssh-keygen','-i','-m','PKCS8' if match[1]=='PUBLIC KEY' else 'PEM','-f',str(source)],capture_output=True,text=True,timeout=10)
        if converted.returncode:raise ValueError()
        text=converted.stdout.strip()
    lines=text.splitlines()
    if len(lines)!=1:raise ValueError()
    parts=lines[0].split()
    allowed={'ssh-ed25519','ssh-rsa','ecdsa-sha2-nistp256','ecdsa-sha2-nistp384','ecdsa-sha2-nistp521','sk-ssh-ed25519@openssh.com','sk-ecdsa-sha2-nistp256@openssh.com'}
    if len(parts)<2 or parts[0] not in allowed:raise ValueError()
    blob=base64.b64decode(parts[1],validate=True)
    size=struct.unpack('>I',blob[:4])[0]
    if blob[4:4+size].decode('ascii')!=parts[0]:raise ValueError()
    pathlib.Path(sys.argv[2]).write_text(parts[0]+' '+parts[1]+'\n')
    if pem:
        exported=subprocess.run(['ssh-keygen','-e','-m','PKCS8','-f',sys.argv[2]],capture_output=True,text=True,timeout=10)
        if exported.returncode or not exported.stdout.startswith('-----BEGIN PUBLIC KEY-----'):raise ValueError()
        pathlib.Path(sys.argv[3]).write_text(exported.stdout)
except (OSError,ValueError,UnicodeError,struct.error,subprocess.SubprocessError):
    sys.exit('Use una sola clave pública OpenSSH (.pub) o PEM, sin opciones, enlaces ni escritura para otros. La clave privada .pem debe permanecer en su equipo.')
PY
    ssh-keygen -l -f "$OCR_TEMP/ssh-key.pub" || fail 'La clave pública SSH no es válida.'
    printf 'Se autorizará esa clave para SSH de bissar; se exigirá autenticación con clave pública.\n'
fi
if (( OCR_CREATE_USER && ! OCR_PASSWORDLESS )); then
    printf 'Se creará la cuenta local bissar con directorio propio.\n'
    if [[ -n "$OCR_USER_PASSWORD_FILE" ]]; then
        OCR_USER_PASSWORD=$(python3 - "$OCR_USER_PASSWORD_FILE" <<'PY'
import os, stat, sys
try:
    fd=os.open(sys.argv[1],os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd,'rb') as f:
        s=os.fstat(f.fileno())
        if not stat.S_ISREG(s.st_mode) or s.st_mode & 0o077 or s.st_size>1024:
            raise ValueError()
        raw=f.read(1025)
    if len(raw)>1024:raise ValueError()
    password=raw.decode('utf-8')
    if password.endswith('\n'):password=password[:-1]
    if password.endswith('\r'):password=password[:-1]
    if not password or any(ord(c)<32 or ord(c)==127 for c in password):raise ValueError()
    sys.stdout.write(password)
except (OSError,ValueError,UnicodeError):
    sys.exit('La contraseña requiere un archivo privado regular de hasta 1024 bytes, sin enlaces y con una sola línea no vacía.')
PY
        ) || fail 'No se pudo leer el archivo de contraseña de bissar.'
    else
        OCR_USER_PASSWORD=$OCR_DEFAULT_USER_PASSWORD_HASH
        OCR_CHPASSWD_ARGS=(--encrypted)
        printf 'Se establecerá automáticamente la contraseña inicial configurada para bissar.\n'
    fi
    [[ -n "$OCR_USER_PASSWORD" && ${#OCR_USER_PASSWORD} -le 1024 && ! "$OCR_USER_PASSWORD" =~ [[:cntrl:]] ]] || fail 'La contraseña de bissar debe ser una sola línea no vacía de hasta 1024 caracteres.'
fi
mkdir -p -- "$(dirname -- "$OCR_DEST")" "$(dirname -- "$OCR_CONFIG")"
chmod 700 -- "$(dirname -- "$OCR_CONFIG")"
OCR_NEEDS_ACTIVATION=1
if [[ -f "$OCR_CONFIG" ]]; then
    chmod 600 -- "$OCR_CONFIG"
    if (( OCR_REACTIVATE == 0 )); then
        # Un JSON con token no prueba que la instalación siga autorizada.
        if python3 - "$OCR_CONFIG" <<'PY'
import json, pathlib, sys
try:
    d=json.loads(pathlib.Path(sys.argv[1]).read_text())
    valid=isinstance(d,dict) and isinstance(d.get('token'),str) and bool(d['token'].strip())
except (ValueError,UnicodeError):
    valid=False
sys.exit(0 if valid else 1)
PY
        then
            printf 'Verificando la activación guardada...\n'
            OCR_CHECK_RC=0
            BISSAR_OCR_CONFIG="$OCR_CONFIG" python3 "$OCR_SOURCE" doctor --raw > "$OCR_TEMP/diagnostico.json" || OCR_CHECK_RC=$?
            (( OCR_CHECK_RC != 130 )) || fail 'Verificación cancelada; configuración conservada.'
            OCR_CHECK_STATE=$(python3 - "$OCR_TEMP/diagnostico.json" <<'PY'
import json,pathlib,sys
try:
    d=json.loads(pathlib.Path(sys.argv[1]).read_text())
    checks=d.get('comprobaciones') or {}
    if d.get('codigo_http') in (401,403):print('CLAVE_REQUERIDA')
    elif checks.get('servidor_activo') is True:print('VIGENTE')
    else:print('NO_VERIFICADA')
except (ValueError,AttributeError,UnicodeError):print('NO_VERIFICADA')
PY
)
            case "$OCR_CHECK_STATE" in
                VIGENTE)
                    OCR_NEEDS_ACTIVATION=0
                    printf 'Licencia vigente. Se conserva la activación actual.\n'
                    if [[ -n "$OCR_KEY" || -n "$OCR_KEY_FILE" ]]; then
                        printf 'Para cambiar la clave existente, ejecute de nuevo con --reactivar.\n'
                    fi
                    ;;
                CLAVE_REQUERIDA)
                    printf 'La activación guardada fue rechazada (HTTP 401/403). Introduzca una clave activa con cupo disponible.\n'
                    ;;
                *) fail 'No se pudo verificar la licencia. Configuración conservada; revise conexión y vuelva a intentarlo. Para cambiarla expresamente use --reactivar.' ;;
            esac
        else
            printf 'La configuración guardada no contiene una activación válida. Se solicitará una clave nueva.\n'
        fi
    fi
fi
if (( OCR_NEEDS_ACTIVATION )); then
    if [[ -n "$OCR_KEY_FILE" ]]; then
        [[ -f "$OCR_KEY_FILE" && ! -L "$OCR_KEY_FILE" ]] || fail 'Archivo de clave inválido.'
        OCR_KEY=$(python3 - "$OCR_KEY_FILE" <<'PY'
import os,stat,sys
p=sys.argv[1]
with open(p) as f:
    s=os.fstat(f.fileno())
    if s.st_mode & 0o077 or s.st_size>256:sys.exit('La clave requiere un archivo privado de hasta 256 bytes.')
    print(f.read().strip())
PY
)
    elif [[ -z "$OCR_KEY" ]]; then
        read -r -s -p 'Clave BISSAR-OCR: ' OCR_KEY < /dev/tty || fail 'No se pudo leer la clave; use --key-file en instalaciones sin terminal.'
        printf '\n' >&2
    fi
    [[ -n "$OCR_KEY" ]] || fail 'La clave no puede estar vacía.'
    # Activar aparte: un rechazo nunca borra la configuración anterior. El
    # candidato queda en el mismo filesystem para publicar con rename atómico.
    OCR_PENDING_DIR=$(mktemp -d "$(dirname -- "$OCR_CONFIG")/.activacion.XXXXXXXX")
    if ! printf '%s\n' "$OCR_KEY" | BISSAR_OCR_CONFIG="$OCR_PENDING_DIR/config.json" python3 "$OCR_SOURCE" activar --clave-stdin --url "$OCR_BASE_URL"; then
        unset OCR_KEY
        # Si ya se recibió y guardó un token, no descartarlo por un fallo posterior.
        if [[ -f "$OCR_PENDING_DIR/config.json" ]]; then
            fail "No se completó la instalación. La nueva configuración se conserva en $OCR_PENDING_DIR/config.json; revise antes de volver a activar."
        fi
        rmdir -- "$OCR_PENDING_DIR"
        fail 'No se confirmó la nueva activación. Se conservó la configuración anterior. Consulte las instalaciones del panel antes de reintentar.'
    fi
    unset OCR_KEY
    [[ ! -L "$OCR_CONFIG" ]] || fail "Destino inválido. La nueva configuración se conserva en $OCR_PENDING_DIR/config.json."
    mv -f -- "$OCR_PENDING_DIR/config.json" "$OCR_CONFIG" || fail "No se pudo publicar la configuración; se conserva en $OCR_PENDING_DIR/config.json."
    rmdir -- "$OCR_PENDING_DIR"
    printf 'Nueva activación guardada en %s\n' "$OCR_CONFIG"
fi
# Reemplazo atómico en el mismo filesystem, con temporal impredecible.
OCR_STAGE=$(mktemp "$(dirname -- "$OCR_DEST")/.bissar-ocr.XXXXXXXX")
trap 'rm -f -- "${OCR_STAGE:-}"; rm -rf -- "$OCR_TEMP"' EXIT
install -m 755 "$OCR_SOURCE" "$OCR_STAGE"
mv -f -- "$OCR_STAGE" "$OCR_DEST"
if (( OCR_CREATE_USER )); then
    # Crear solo tras instalar/activar el CLI. useradd falla si otra instalación
    # creó la cuenta entretanto; nunca se cambia la contraseña de esa cuenta.
    printf 'Creando la cuenta local bissar...\n'
    useradd --create-home --user-group --home-dir "$OCR_USER_HOME" --shell /bin/bash \
        --comment 'BISSAR CLI' --key UMASK=077 --key HOME_MODE=0700 "${OCR_USERADD_ARGS[@]}" bissar \
        || fail 'El CLI quedó instalado, pero no se pudo crear bissar. Revise la cuenta y vuelva a ejecutar el instalador.'
    if (( OCR_PASSWORDLESS )); then
        printf 'Cuenta bissar creada sin contraseña utilizable. El acceso se configurará mediante clave SSH.\n'
    else
        if ! printf 'bissar:%s\n' "$OCR_USER_PASSWORD" | chpasswd "${OCR_CHPASSWD_ARGS[@]}" > "$OCR_TEMP/usuario.log" 2>&1; then
            unset OCR_USER_PASSWORD
            fail 'Se creó bissar, pero no se confirmó su contraseña. Establézcala localmente con: sudo passwd bissar'
        fi
        printf 'Cuenta bissar creada con contraseña y directorio privado.\n'
    fi
    unset OCR_USER_PASSWORD
fi
if [[ -n "$OCR_SSH_KEY_FILE" ]]; then
    python3 - "$OCR_TEMP/ssh-key.pub" "$OCR_SSH_CONFIG" "$OCR_SSH_KEYS" "$OCR_SSH_SERVICE" "$OCR_TEMP/ssh-public.pem" <<'PY'
import base64,fcntl,fnmatch,glob,hashlib,json,os,pathlib,shlex,shutil,stat,subprocess,sys,tempfile

key_source,config,keys=map(pathlib.Path,sys.argv[1:4])
service=sys.argv[4]
begin='# BEGIN BISSAR CLI SSH'
end='# END BISSAR CLI SSH'
header='# BISSAR CLI SSH authorized keys\n'
backup=None

def run(*args):
    result=subprocess.run(args,capture_output=True,text=True,timeout=20)
    if result.returncode:raise ValueError('Falló la comprobación o recarga de SSH: '+args[0])
    return result.stdout

def safe(path,directory=False):
    info=path.lstat()
    expected=stat.S_ISDIR if directory else stat.S_ISREG
    if not expected(info.st_mode) or info.st_uid!=os.geteuid() or info.st_mode & 0o022:
        raise ValueError('Propietario, permisos o tipo de archivo SSH inválidos: '+str(path))
    return info

def effective(user):
    raw=run('sshd','-T','-f',str(config),'-C','user='+user+',host=localhost,addr=127.0.0.1')
    return dict(line.split(None,1) for line in raw.splitlines() if len(line.split(None,1))==2)

def atomic(path,content,mode):
    fd,name=tempfile.mkstemp(prefix='.bissar-ssh-',dir=str(path.parent))
    try:
        with os.fdopen(fd,'wb') as f:
            os.fchmod(f.fileno(),mode);f.write(content);f.flush();os.fsync(f.fileno())
        os.replace(name,path)
    finally:
        if os.path.exists(name):os.unlink(name)

def check_conditional_methods(content):
    # Una comprobación -T para loopback no cubre Match Address/Host de otras
    # conexiones. No aceptar métodos condicionales que puedan ganar prioridad
    # sobre nuestro bloque y volver a admitir la contraseña compartida.
    conditional=False;seen=0
    def visit(text):
        nonlocal conditional,seen
        seen+=1
        if seen>128:raise ValueError('Demasiados Include en la configuración SSH.')
        for line in text.splitlines():
            words=shlex.split(line,comments=True)
            if not words:continue
            if '=' in words[0]:
                name,value=words[0].split('=',1)
                words=[name]+([value] if value else [])+words[1:]
            elif len(words)>1 and words[1].startswith('='):
                words=words[:1]+([words[1][1:]] if words[1][1:] else [])+words[2:]
            name=words[0].lower()
            if name=='match':conditional=True
            if name=='authenticationmethods' and conditional and words[1:]!=['publickey']:
                raise ValueError('SSH contiene métodos de autenticación condicionales; integre la autorización de bissar en esa política antes de continuar.')
            if name=='include':
                for pattern in words[1:]:
                    if not os.path.isabs(pattern):pattern=str(config.parent/pattern)
                    for included in sorted(glob.glob(pattern)):visit(pathlib.Path(included).read_text())
    visit(content)

try:
    root_info=safe(config.parent,True)
    if root_info.st_mode & 0o005!=0o005:raise ValueError('El directorio SSH necesita acceso de lectura para bissar.')
    lock=os.open(str(config.parent/'.bissar-cli.lock'),os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,0o600)
    lock_info=os.fstat(lock)
    if not stat.S_ISREG(lock_info.st_mode) or lock_info.st_uid!=os.geteuid() or lock_info.st_mode & 0o077:
        raise ValueError('Archivo de bloqueo SSH inválido.')
    fcntl.flock(lock,fcntl.LOCK_EX)
    config_info=safe(config)
    original=config.read_bytes()
    text=original.decode('utf-8')
    if text.count(begin)!=text.count(end) or text.count(begin)>1:
        raise ValueError('El bloque SSH de Bissar está incompleto; revíselo manualmente.')
    if begin in text:
        prefix,tail=text.split(begin,1)
        if tail.split(end,1)[1].strip():raise ValueError('El bloque SSH de Bissar debe estar al final del archivo.')
    else:prefix=text
    check_conditional_methods(prefix)
    account=run('getent','passwd','bissar').strip().split(':')
    if len(account)!=7 or account[0]!='bissar' or int(account[2])==0 or not os.access(account[6],os.X_OK) or pathlib.Path(account[6]).name in ('nologin','false'):
        raise ValueError('La cuenta bissar necesita una shell de acceso válida y un UID distinto de root.')
    before=effective('bissar')
    others={user:effective(user) for user in ('root','nobody')}
    if before.get('authenticationmethods') not in ('any','publickey'):
        raise ValueError('SSH exige autenticación adicional para bissar. Se conserva esa política; revísela antes de instalar la clave.')
    if before.get('forcecommand','none')!='none' or before.get('chrootdirectory','none')!='none':
        raise ValueError('SSH tiene restricciones de comando o chroot para bissar; revíselas antes de habilitar una shell.')
    if before.get('allowgroups') or before.get('denygroups'):
        raise ValueError('SSH limita el acceso por grupos. Revise la autorización de bissar en esa política antes de continuar.')
    allowed=before.get('allowusers','').split()
    denied=before.get('denyusers','').split()
    if allowed and not any(fnmatch.fnmatchcase('bissar',s.split('@',1)[0]) for s in allowed):
        raise ValueError('AllowUsers no permite bissar; añádalo a la política SSH antes de continuar.')
    if any('@' not in s and fnmatch.fnmatchcase('bissar',s) for s in denied):
        raise ValueError('DenyUsers bloquea bissar; revise la política SSH antes de continuar.')
    if keys.parent.parent!=config.parent:raise ValueError('El archivo de claves debe estar dentro del directorio SSH.')
    if not keys.parent.exists():
        keys.parent.mkdir(mode=0o755);keys.parent.chmod(0o755)
    safe(keys.parent,True)
    # Las claves son públicas y deben ser legibles por el usuario autenticado.
    if keys.parent.stat().st_mode & 0o005!=0o005:
        raise ValueError('El directorio de claves SSH necesita lectura y acceso para bissar.')
    old_keys=None;old_keys_mode=0o644
    if keys.exists() or keys.is_symlink():
        old_keys_info=safe(keys);old_keys_mode=stat.S_IMODE(old_keys_info.st_mode)
        if old_keys_info.st_size>1048576:raise ValueError('El archivo de claves SSH supera el tamaño permitido.')
        old_keys=keys.read_bytes()
        if not old_keys.startswith(header.encode()):raise ValueError('El archivo de claves SSH existente no pertenece al instalador; se conserva.')
    public=key_source.read_text().strip()
    pem_source=pathlib.Path(sys.argv[5]);pem_target=None;old_pem=None;pem_content=None
    if pem_source.exists():
        pem_content=pem_source.read_bytes()
        fingerprint=hashlib.sha256(base64.b64decode(public.split()[1])).hexdigest()
        pem_target=keys.parent/('public-'+fingerprint+'.pem')
        if pem_target.exists() or pem_target.is_symlink():
            safe(pem_target);old_pem=pem_target.read_bytes()
            if old_pem!=pem_content:raise ValueError('La copia pública PEM existente no coincide; se conserva.')
    existing=(old_keys or header.encode()).decode('utf-8')
    if public not in [line.strip() for line in existing.splitlines()]:existing=existing.rstrip('\n')+'\n'+public+'\n'
    paths=shlex.split(before.get('authorizedkeysfile','.ssh/authorized_keys'))
    paths=[p for p in paths if p!='none' and p!=str(keys)]+[str(keys)]
    block=(begin+'\nMatch User bissar\n    AuthenticationMethods publickey\n'
           '    PubkeyAuthentication yes\n    PasswordAuthentication no\n'
           '    KbdInteractiveAuthentication no\n    AuthorizedKeysFile '+
           ' '.join(json.dumps(p) for p in paths)+'\nMatch all\n'+end+'\n')
    candidate=(prefix.rstrip('\n')+'\n\n'+block).encode()
    if config.read_bytes()!=original:raise ValueError('La configuración SSH cambió durante la instalación.')
    backup=pathlib.Path(tempfile.mkdtemp(prefix='.bissar-ssh-backup-',dir=str(config.parent)))
    (backup/'sshd_config').write_bytes(original)
    if old_keys is not None:(backup/'authorized_keys').write_bytes(old_keys)
    if old_pem is not None:(backup/'public.pem').write_bytes(old_pem)
    reload_attempted=False
    try:
        if pem_target is not None and old_pem is None:atomic(pem_target,pem_content,0o644)
        atomic(keys,existing.encode(),0o644)
        atomic(config,candidate,stat.S_IMODE(config_info.st_mode))
        run('sshd','-t','-f',str(config))
        after=effective('bissar')
        expected={'authenticationmethods':'publickey','pubkeyauthentication':'yes','passwordauthentication':'no','kbdinteractiveauthentication':'no'}
        if any(after.get(k)!=v for k,v in expected.items()) or str(keys) not in shlex.split(after.get('authorizedkeysfile','')):
            raise ValueError('Otra regla SSH impide aplicar el acceso con clave pública a bissar.')
        if any(effective(user)!=values for user,values in others.items()):
            raise ValueError('El cambio alteraría la configuración SSH de otros usuarios.')
        reload_attempted=True
        run('systemctl','reload',service)
        run('systemctl','is-active','--quiet',service)
    except Exception:
        try:
            atomic(config,original,stat.S_IMODE(config_info.st_mode))
            if old_keys is None:keys.unlink(missing_ok=True)
            else:atomic(keys,old_keys,old_keys_mode)
            if pem_target is not None and old_pem is None:pem_target.unlink(missing_ok=True)
            if reload_attempted:
                run('sshd','-t','-f',str(config));run('systemctl','reload',service)
        except Exception:
            raise ValueError('No se confirmó la restauración de SSH. Copia de recuperación conservada en '+str(backup))
        shutil.rmtree(backup);backup=None
        raise
    shutil.rmtree(backup);backup=None
    print('Clave pública autorizada para bissar. SSH recargado; conecte con: ssh -i /ruta/clave_privada bissar@SERVIDOR')
    if pem_target is not None:print('PEM público instalado en '+str(pem_target))
    if any('@' in s for s in allowed+denied):print('Se conservan las restricciones SSH de origen existentes.')
except (OSError,ValueError,subprocess.SubprocessError) as exc:
    sys.exit('No se completó la configuración SSH: '+str(exc))
PY
fi
if (( OCR_SERVICE )); then
    cat > "$OCR_TEMP/bissar-ocr.service" <<'UNIT'
[Unit]
Description=BISSAR-OCR Local Web Bridge
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
DynamicUser=yes
LoadCredential=config.json:/etc/bissar-ocr/config.json
Environment=BISSAR_OCR_CONFIG=%d/config.json
ExecStart=/usr/local/bin/bissar-ocr serve --host 127.0.0.1 --port 8585
Restart=on-failure
RestartSec=5
TimeoutStopSec=330
KillSignal=SIGINT
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
TasksMax=32
LimitNOFILE=256

[Install]
WantedBy=multi-user.target
UNIT
    install -m 644 "$OCR_TEMP/bissar-ocr.service" /etc/systemd/system/bissar-ocr.service
    systemctl daemon-reload
    systemctl enable bissar-ocr.service
    systemctl restart bissar-ocr.service
    systemctl is-active --quiet bissar-ocr.service || fail 'El servicio no pudo iniciar.'
fi
printf 'BISSAR-OCR instalado. Configuración disponible en %s\n' "$OCR_CONFIG"
printf 'Compruebe conexión y compatibilidad con: %s doctor\n' "$OCR_DEST"
if (( OCR_SSH_AUTO )); then
    printf 'SSH de bissar listo con la clave generada. PEM privado: %s/bissar-access.pem\n' "$OCR_SSH_IDENTITY_DIR"
    printf 'Para copiar el PEM a su equipo y comprobar la conexión automáticamente, ejecute allí: bash install.sh --ssh-remote root@SERVIDOR\n'
    printf 'La clave privada no se muestra en los registros ni se envía al panel. Consérvela para conectar por SSH.\n'
fi
