#!/bin/bash

#----------------------------------------------------------
# deepines-asistente es un script que ayuda a subir, actualizar 
# y eliminar paquetes en el repositorio deepines de Deepin en Español
#
# Versión: 2.0.2 (alta automática, VPN y SSH; validación de la cuenta local corregida)
#
# Autor: Isaías Gätjens M.
# Email: igatjens@gmial.com
# Telegram: https://t.me/igatjens
# Github: https://github.com/igatjens
#
# Deepin en Español
# https://deepinenespañol.org/
#----------------------------------------------------------

CARPETA_TRABAJO="${DEEPINES_WORKDIR:-$HOME/repositorio_deepines_vpn}"
set -o pipefail
shopt -s nullglob
SSH_OPTS=(-o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2)
CARPETA_PARA_SUBIR=$CARPETA_TRABAJO/para_subir
CARPETA_SUBIDOS=$CARPETA_TRABAJO/subidos
CARPETA_GIT=$CARPETA_TRABAJO/git

ARCHIVO_CONFIG=$CARPETA_TRABAJO/.config
ARCHIVO_ELIMINAR="$CARPETA_PARA_SUBIR/eliminar.txt"

OPCION=""

# Servidor VPS
NOMBRE_USUARIO=""
REPO_SELECCIONADO=""
PUERTO_SELECCIONADO=""
SERVIDOR=""
CANTIDAD_REPO=""
LISTA_REPO=""

# Repositorio seleccionado
REPO_NUMERO_SELECIONADO=""

# Repositorio git
REPO_GIT_URL=""
REPO_GIT_RAMA=""
REPO_GIT_NOMBRE=""

CARPETA_BANNERS="deepinesStore/resources/apps"
ARCHIVO_EXCLUIDOS="deepinesStore/config/excluidos.txt"
ARCHIVO_APPS_DEEPINES="deepinesStore/config/deepines.txt"


proc_salir () {
	cerrar_sesion_mantenedor
}

proc_cancelado () {

	echo "Cancelando... OK"
	
}

opcion_invalida () {
	
	echo "Opcion inválida"
	enter_para_continuar

}

comprobar_entorno_trabajo () {
 mkdir -p -- "$CARPETA_TRABAJO" "$CARPETA_PARA_SUBIR" "$CARPETA_SUBIDOS" "$CARPETA_GIT"
}

comprobar_repostorio_git () {
 # La subida de paquetes no requiere configurar ni clonar Git.
 command -v git >/dev/null 2>&1 || { echo "Instala Git para usar las funciones de la tienda." >&2; return 1; }
 [[ -n "$REPO_GIT_URL" && -n "$REPO_GIT_RAMA" ]] || { echo "Configura git_repo para usar las funciones de la tienda." >&2; return 1; }
 local folder="$CARPETA_GIT/$REPO_GIT_NOMBRE"
 if [[ -d "$folder/.git" ]]; then
  git -C "$folder" checkout "$REPO_GIT_RAMA"
 else
  git clone --branch "$REPO_GIT_RAMA" -- "$REPO_GIT_URL" "$folder"
 fi
}

aplicar_plantilla_config () {
 cat > "$ARCHIVO_CONFIG" <<'CONFIG'
# Alias de ~/.ssh/config con HostName VPN, User e IdentityFile.
servidor-ssh=
vpn-perfil=RepoDeepines
nombre-usuario=
# vps_repo=Nombre Puerto Numero (por ejemplo Deepines_6 22 6)
vps_repo=
# Opcional: repositorio Git y rama para las funciones de la tienda.
git_repo=
CONFIG
}

comprobar_config_repositorios () {
 comprobar_entorno_trabajo || return
 if [[ ! -f "$ARCHIVO_CONFIG" ]]; then
  aplicar_plantilla_config || return
  chmod 600 "$ARCHIVO_CONFIG"
 fi
}

editar_config () {

	#Abre el archivo de configuración
	clear
	mostrar_estado
	echo "Abriendo el archivo de configuración"
	abrir_archivo $ARCHIVO_CONFIG
	echo "No olvide guardar los cambios"
	enter_para_continuar
}

enter_para_continuar () {
 [[ ${DEEPINES_NO_PAUSE:-0} == 1 || ${SEGUIR_MP:-true} == false ]] && return 0
 echo 'Presione ENTER para continuar'
 read -r _
}

obtener_lista_repo () {
 LISTA_REPO=""; CANTIDAD_REPO=0; REPO_GIT_URL=""; REPO_GIT_RAMA=""; REPO_GIT_NOMBRE=""; SERVIDOR=""; NOMBRE_USUARIO=""; VPN_PERFIL="RepoDeepines"
 local line key value name port number extra
 while IFS= read -r line || [[ -n "$line" ]]; do
  line=${line%%#*}; [[ "$line" == *'='* ]] || continue
  key=${line%%=*}; value=${line#*=}
  key=$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
  value=$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
  case "$key" in
   servidor-ssh) SERVIDOR=$value ;;
   vpn-perfil) [[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$ ]] || return 1; VPN_PERFIL=$value ;;
   nombre-usuario) NOMBRE_USUARIO=$value ;;
   vps_repo)
    [[ -n "$value" ]] || continue
    read -r name port number extra <<< "$value"
    if [[ ! "$name" =~ ^[A-Za-z0-9_.-]+$ || ! "$port" =~ ^[0-9]{1,5}$ || ! "$number" =~ ^[0-9]+$ || -n "$extra" ]] || ((10#$port<1 || 10#$port>65535)); then
     echo "Configuración de repositorio inválida: $value" >&2; return 1
    fi
    LISTA_REPO+="$name;$((10#$port));$number "
    CANTIDAD_REPO=$((CANTIDAD_REPO+1)) ;;
   git_repo) read -r REPO_GIT_URL REPO_GIT_RAMA extra <<< "$value" ;;
  esac
 done < "$ARCHIVO_CONFIG"
 REPO_GIT_NOMBRE=${REPO_GIT_URL##*/}; REPO_GIT_NOMBRE=${REPO_GIT_NOMBRE%.git}
}

comprobar_lista_repo () {
 [[ -n "$LISTA_REPO" ]] || { echo 'Configura al menos un repositorio.' >&2; return 1; }
 [[ "$SERVIDOR" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { echo 'Configura servidor-ssh con el alias SSH del repositorio.' >&2; return 1; }
}

comprobar_nombre_usuario () {
 [[ "$NOMBRE_USUARIO" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo 'Nombre de usuario inválido.' >&2; return 1; }
}

obtener_nombre_usuario () {
 # Se obtiene junto con los demás campos, sin ejecutar la configuración.
 printf 'Usuario: %s\n' "$NOMBRE_USUARIO"
}

seleccionar_repo () {

	SEGUIR_SR=true	
	while [[ $SEGUIR_SR == true ]]; do

		clear
		echo "Seleccione uno de los repositorios"

		CONTADOR=1
		for REPO in $LISTA_REPO; do

			echo " $CONTADOR) $(echo $REPO | cut -d ";" -f1)"
			let CONTADOR++

		done

		echo " C) Cancelar"
		echo -e " S) Salir\n"

		read -r -p "Opción (confirma con ENTER): " OPCION || return 1

		#si la opción es un número
		if [[ $OPCION =~ ^[0-9]+$ ]]; then
			
			if [[ $OPCION -gt 0 && $OPCION -le $CANTIDAD_REPO ]]; then
				
				REPO=$(echo $LISTA_REPO | cut -d " " -f$OPCION)
				REPO_SELECCIONADO=$(echo $REPO | cut -d ";" -f1)
				PUERTO_SELECCIONADO=$(echo $REPO | cut -d ";" -f2)
				REPO_NUMERO_SELECIONADO=$(echo $REPO | cut -d ";" -f3)
				echo "Repositorio seleccionado... $REPO_SELECCIONADO"
				echo "Puerto seleccionado... $PUERTO_SELECCIONADO"
				echo "Número de repositorio seleccionado... $REPO_NUMERO_SELECIONADO"
				enter_para_continuar
				SEGUIR_SR=false

			else

				opcion_invalida

			fi

			
		else
			case $OPCION in

				C|c)
					if [[ $REPO_SELECCIONADO == "" ]]; then

						clear
						echo "No ha seleccionado un repositorio, debe seleccionar uno o seleccione salir"
						enter_para_continuar

					else

						SEGUIR_SR=false
						proc_cancelado

					fi
					
				;;

				S|s)
					SEGUIR_SR=false
					proc_salir
				;;

				*)
					opcion_invalida
				;;

			esac
		fi

	done

}

mostrar_estado () {
 printf '%s\n' '----------------------------------------------------------'
 printf 'Repo: %s | Servidor: %s | Puerto: %s | Usuario: %s\n' "$REPO_SELECCIONADO" "$SERVIDOR" "$PUERTO_SELECCIONADO" "$NOMBRE_USUARIO"
 printf '%s\n' 'VPN automática y acceso por clave SSH.' '----------------------------------------------------------'
}


validar_conexion_config () {
 configurar_opciones_ssh
 comprobar_lista_repo && comprobar_nombre_usuario || return 1
 [[ "$PUERTO_SELECCIONADO" =~ ^[0-9]{1,5}$ ]] && ((10#$PUERTO_SELECCIONADO>=1 && 10#$PUERTO_SELECCIONADO<=65535)) || { echo 'Puerto inválido.' >&2; return 1; }
}

comprobar_acceso_ssh () {
 validar_conexion_config || return
 ssh "${SSH_OPTS[@]}" -p "$PUERTO_SELECCIONADO" "$NOMBRE_USUARIO@$SERVIDOR" true || {
  echo 'No se pudo acceder. Comprueba la VPN, la clave autorizada y la huella del servidor. Si la clave tiene frase de paso, cárgala antes con ssh-add.' >&2
  return 1
 }
}

subir_por_rsync () {
 local rsh
 printf -v rsh '%q ' ssh "${SSH_OPTS[@]}" -p "$PUERTO_SELECCIONADO"
 rsync -rtv --delay-updates --chmod=F644 --progress -e "$rsh" -- "$@" "$NOMBRE_USUARIO@$SERVIDOR:/var/tmp/deepines/"
}

validar_debs () {
 local f
 for f in "$@"; do
  dpkg-deb --info "$f" >/dev/null 2>&1 || { echo "Paquete inválido: $f" >&2; return 1; }
 done
}

comprobar_permisos_debs () {
 local f
 for f in "$@"; do
  if ! dpkg-deb --contents "$f" | awk '$2 != "root/root" && $2 != "0/0" {bad=1} /^[-d].......w/ {bad=1} END {exit bad}'; then
   echo "Revisa los permisos de $f. Si deb-tools lo reempaquetó, usa el archivo corregido de ~/deb-tools/reempaquetados/." >&2
   return 1
  fi
 done
}

confirmar_operacion () {
 local respuesta
 printf '%s (s/N): ' "$1"
 read -r respuesta || return 1
 [[ "$respuesta" == s || "$respuesta" == S ]]
}

operacion_remota () {
 local titulo=$1; shift
 clear; mostrar_estado
 confirmar_operacion "$titulo" || { echo 'Operación cancelada.'; return 0; }
 ejecutar_comando_en_servidor "$@"
 local rc=$?
 if ((rc==0)); then echo 'Operación completada.'; else echo "La operación ha fallado (código $rc)." >&2; fi
 enter_para_continuar
 return "$rc"
}

subir_archivos_a_repo () {
 local f rc=0
 local -a archivos=("$CARPETA_PARA_SUBIR"/*.deb)
 ((${#archivos[@]})) || { echo 'No hay paquetes .deb para subir.'; enter_para_continuar; return 0; }
 printf 'Preparando la subida de %s paquete(s). Comprobando VPN y SSH...\n' "${#archivos[@]}"
 asegurar_acceso || return 1
 echo 'Acceso comprobado. Validando paquetes .deb...'
 validar_debs "${archivos[@]}" || return 1
 if command -v deb-tools-namefix >/dev/null; then echo 'Comprobando nombres con deb-tools-namefix...'; deb-tools-namefix "$CARPETA_PARA_SUBIR" || return 1; fi
 if command -v deb-tools-permfix >/dev/null; then echo 'Comprobando permisos con deb-tools-permfix...'; deb-tools-permfix "$CARPETA_PARA_SUBIR" || return 1; fi
 archivos=("$CARPETA_PARA_SUBIR"/*.deb)
 echo 'Validando contenido y permisos finales...'
 validar_debs "${archivos[@]}" && comprobar_permisos_debs "${archivos[@]}" || return 1
 for f in "${archivos[@]}"; do
  [[ ! -e "$CARPETA_SUBIDOS/${f##*/}" ]] || { echo "Ya existe en subidos: ${f##*/}. Revísalo antes de continuar." >&2; return 1; }
 done
 echo 'Iniciando la transferencia al repositorio...'
 if subir_por_rsync "${archivos[@]}"; then
  printf 'publicar\n' > "$CARPETA_TRABAJO/.publicacion-pendiente"
  mv -- "${archivos[@]}" "$CARPETA_SUBIDOS/" || rc=$?
  ((rc)) || echo 'Subida completada. Copias conservadas en subidos.'
  ((rc)) || fin_de_subida
 else
  rc=$?; echo 'La subida ha fallado. Los paquetes permanecen en para_subir.' >&2
 fi
 enter_para_continuar
 return "$rc"
}

actualizar_repositorio () {
 confirmar_operacion '¿Publicar los paquetes pendientes?' || return 0
 ejecutar_comando_en_servidor sudo -n -- /usr/local/bin/actualiza || return
 printf 'sincronizar\n' > "$CARPETA_TRABAJO/.publicacion-pendiente"
}

actualizar_lista_paquetes_html () {
 operacion_remota '¿Actualizar la lista HTML de paquetes?' sudo -n -- /usr/local/bin/listarpaquetes
}

sincronizar_repositorio () {
 [[ "$REPO_NUMERO_SELECIONADO" =~ ^[0-9]+$ ]] || { echo 'Número de repositorio inválido.' >&2; return 1; }
 confirmar_operacion '¿Sincronizar la publicación con el espejo?' || return 0
 ejecutar_comando_en_servidor sudo -n -- /usr/local/bin/deepines-sincroniza "$REPO_NUMERO_SELECIONADO" || return
 rm -f -- "$CARPETA_TRABAJO/.publicacion-pendiente"
}

iniciar_sesion_servidor () {
 validar_conexion_config || return
 ssh "${SSH_OPTS[@]}" -t -p "$PUERTO_SELECCIONADO" "$NOMBRE_USUARIO@$SERVIDOR"
}

eliminar_paquetes_repo () {
 touch -- "$ARCHIVO_ELIMINAR" || return
 echo 'Escribe los nombres de paquetes que quieres eliminar, separados por espacios.'
 abrir_archivo "$ARCHIVO_ELIMINAR"; enter_para_continuar
 local nombre
 local -a nombres=()
 read -r -a nombres <<< "$(tr '\n' ' ' < "$ARCHIVO_ELIMINAR")"
 ((${#nombres[@]})) || { echo 'La lista está vacía.'; return 0; }
 for nombre in "${nombres[@]}"; do
  [[ "$nombre" =~ ^[a-z0-9][a-z0-9+.-]+$ ]] || { echo "Nombre de paquete inválido: $nombre" >&2; return 1; }
 done
 confirmar_operacion "¿Enviar la solicitud para eliminar ${#nombres[@]} paquete(s)?" || return 0
 asegurar_acceso || return 1
 subir_por_rsync "$ARCHIVO_ELIMINAR" || return
 mv -- "$ARCHIVO_ELIMINAR" "$CARPETA_SUBIDOS/eliminar-$(date +%Y%m%d-%H%M%S).txt" || return
 echo 'Solicitud subida. Se procesará al ejecutar Actualizar.'
 enter_para_continuar
}

#Borra los .deb de la carpeta subidos
vaciar_carpeta_subidos () {
 local -a archivos=("$CARPETA_SUBIDOS"/*.deb)
 ((${#archivos[@]})) || { echo 'No hay paquetes archivados.'; return 0; }
 printf '%s\n' "${archivos[@]}"
 confirmar_operacion '¿Borrar estas copias locales de subidos?' || return 0
 rm -- "${archivos[@]}"
}

ejecutar_comando_en_servidor () {
 asegurar_acceso || return
 validar_conexion_config || return
 (($#)) || { echo 'Falta el comando.' >&2; return 1; }
 ssh "${SSH_OPTS[@]}" -p "$PUERTO_SELECCIONADO" "$NOMBRE_USUARIO@$SERVIDOR" "$@"
}

descargar_actualizaciones_tienda () {

	clear
	mostrar_estado
	echo "Descargando actualizaciones del repositorio git de Tienda Deepines"
	cd $CARPETA_GIT/$REPO_GIT_NOMBRE
	git pull

	enter_para_continuar
}

subir_actualizaciones_tienda () {

	clear
	mostrar_estado
	echo "Subiendo actualizaciones del repositorio git de Tienda Deepines"
	cd $CARPETA_GIT/$REPO_GIT_NOMBRE

	echo "Agregando archivos"

	git add ./$CARPETA_BANNERS/*
	git add ./$ARCHIVO_APPS_DEEPINES
	git add ./$ARCHIVO_EXCLUIDOS

	enter_para_continuar

	clear
	mostrar_estado

	git commit

	enter_para_continuar

	clear
	mostrar_estado
	echo -e "¿Quiere continuar con el proceso de subir las actualizaciones al repositorio git de la Tienda Deepines?"

	echo -e " S) Si)"
	echo -e " N) No)"

	read -r OPCION || return 1

	case $OPCION in

		S|s)
			clear
			mostrar_estado
			git push
		;;

		*)
			echo "Proceso de subir actualizaciones cancelado"
		;;

	esac

	enter_para_continuar
}

mostrar_usuario_email_global_git () {
	clear
	mostrar_estado
	echo
	echo "La configuración actual del usuario global de git es la siguiente"
	echo
	git config --global --list
	echo
	enter_para_continuar
}

configurar_usuario_global_git () {

	mostrar_usuario_email_global_git

	echo "¿Quiere cambiar el usuario y email?"
	echo " S) Sí"
	echo " N) No"

	read -r OPCION || return 1

	if [[ "$OPCION" == "S" || "$OPCION" == "s" ]]; then
		
		NOMBRE_USUARIO_GIT=""
		EMAIL_USURIO_GIT=""

		clear
		mostrar_estado

		echo
		echo "Escriba el nombre de usuario global de git"
		read NOMBRE_USUARIO_GIT

		git config --global user.name "$NOMBRE_USUARIO_GIT"

		clear
		mostrar_estado

		echo
		echo "Escriba su dirección de correo para git"
		read EMAIL_USURIO_GIT

		git config --global user.email "$EMAIL_USURIO_GIT"


		clear
		mostrar_estado

		echo
		echo "Se configuró el siguiente nombre de usuario y email"
		echo

		git config --global --list
		echo

		enter_para_continuar
	fi

}

configurar_token_github () {

	clear
	mostrar_estado

	echo
	echo "A continuación se abrirá el sitio web de configuración de tokens de github"
	echo "Asegúrese de activar los permisos de escritura del token que va a crear"
	echo
	enter_para_continuar

	xdg-open "https://github.com/settings/tokens"
}

menu_principal () {

	SEGUIR_MP=true	
	while [[ $SEGUIR_MP == true ]]; do

		clear
		mostrar_estado
		echo "Menú pricipal - Seleccione una opción"
		echo
		echo " 1)   Paquetes para subir - Abrir carpeta"
		echo " 2)   Paquetes subidos    - Abrir carpeta"
		echo " 3)   Paquetes subidos    - Vaciar carpeta"
		echo 
		echo " 4)   Subir archivos al repositorio"
		echo " 5)   Eliminar paquetes del repositorio"
		echo 
		echo " 6)   Servidor - Actualizar manualmente el repositorio"
		echo " 7)   Servidor - Actualizar manualmente la lista de paquetes.html"
		echo " 8)   Servidor - Sincronizar repositorios"
		echo " 9)   Servidor - Iniciar sesión"
		echo 
		echo " 10)  Tienda - Descargar actualizaciones"
		echo " 11)  Tienda - Banners de la tienda  - Abrir carpeta"
		echo " 12)  Tienda - Aplicaciones Deepines - Abrir archivo"
		echo ' 13)  Tienda - Paquetes ocultos      - Abrir archivo'
		echo ' 14)  Tienda - Subir archivos'
		echo
		echo " 15)  Repositorio - Cambiar"
		echo " 16)  Repositorio - Configurar"
		echo
		echo " 17)  Git - Configurar usuario global"
		echo " 18)  Git - Configurar token github"
	echo " 19)  VPN - Preparar, conectar o ver estado"
	echo " 20)  Acceso - Solicitud, respuesta y comprobación"
		echo
		echo -e " S)   Salir\n"

		read -r -p "Opción (confirma con ENTER): " OPCION || return 1

		case $OPCION in

			1)
				abrir_carpeta "$CARPETA_PARA_SUBIR"
			;;

			2)
				abrir_carpeta "$CARPETA_SUBIDOS"
			;;

			3)
				vaciar_carpeta_subidos
			;;

			4)
				if ! subir_archivos_a_repo; then
                    echo 'No se ha completado la subida. Revisa el mensaje anterior.' >&2
                    enter_para_continuar
                fi
			;;

			5)
				eliminar_paquetes_repo

			;;

			6)
				actualizar_repositorio
			;;

			7)
				actualizar_lista_paquetes_html
			;;

			8)
				sincronizar_repositorio
			;;

			9)
				iniciar_sesion_servidor
			;;

			10)
				comprobar_repostorio_git && descargar_actualizaciones_tienda
			;;

			11)
				abrir_carpeta "$CARPETA_GIT/$REPO_GIT_NOMBRE/$CARPETA_BANNERS"
			;;

			12)
				abrir_archivo "$CARPETA_GIT/$REPO_GIT_NOMBRE/$ARCHIVO_APPS_DEEPINES"
			;;

			13)
				abrir_archivo "$CARPETA_GIT/$REPO_GIT_NOMBRE/$ARCHIVO_EXCLUIDOS"
			;;

			14)
				comprobar_repostorio_git && subir_actualizaciones_tienda
			;;

			15)
				seleccionar_repo
			;;

			16)
				editar_config
				secuencia_inicio
			;;

			17)
				configurar_usuario_global_git
			;;

			20)
				menu_alta
				enter_para_continuar
			;;

			19)
				menu_vpn
			;;

			18)
				configurar_token_github
			;;


			S|s)
				SEGUIR_MP=false
				proc_salir
			;;

			*)
				opcion_invalida
			;;

		esac

	done

}

secuencia_inicio () {
 comprobar_config_repositorios || return 1
 while ! { obtener_lista_repo && comprobar_lista_repo && comprobar_nombre_usuario; }; do
  echo "Completa $ARCHIVO_CONFIG con el alias SSH, usuario y repositorio."
  abrir_archivo "$ARCHIVO_CONFIG"
  enter_para_continuar || return 1
 done
 seleccionar_repo
 [[ ! -f "$CARPETA_TRABAJO/.publicacion-pendiente" ]] || echo "Tienes una publicación pendiente. Completa Actualizar y Sincronizar antes de terminar."
}

comprobar_comandos_requeridos () {
 local comando falta=0
 for comando in cut wc xdg-open rsync ssh sed grep touch dpkg-deb awk; do
  command -v "$comando" >/dev/null 2>&1 || { echo "Falta el comando: $comando" >&2; falta=1; }
 done
 ((falta==0)) || { echo 'Instala las dependencias antes de continuar; no se ha realizado ninguna subida.' >&2; return 1; }
}

abrir_archivo () {

	xdg-open "$@"
	VALOR_RETORNO=$?

	#si xdg-open devolvio código de error, entonces abri con nano
	if [[ $VALOR_RETORNO != "0" ]]; then

		echo -e "\n\n----------------------------------------------------------"
		echo -e "Hubo algún problema al abrir el archivo \n$@"
		echo -e "Por eso se abrirá con nano\n"

		enter_para_continuar
		
		nano "$@"
	fi

	return 0
}

abrir_carpeta () {
 local ruta=$1
 [[ -d "$ruta" ]] || { printf 'No existe la carpeta: %s\n' "$ruta" >&2; enter_para_continuar; return 1; }
 # Some desktop openers live as long as their window. They must neither block
 # this menu nor consume its keyboard input.
 xdg-open "$ruta" </dev/null >/dev/null 2>&1 &
 disown "$!" 2>/dev/null || true
}

# Included in the standalone assistant by build-assistant.py.
VPN_PERFIL="RepoDeepines"
DIRECTORIO_CLAVES="$HOME/.config/deepines"

vpn_admin () {
 if ((EUID==0)); then "$@"; else sudo -- "$@"; fi
}

preparar_directorio_claves () {
 local carpeta usuario=${SUDO_USER:-$(id -un)}
 for carpeta in "$DIRECTORIO_CLAVES" "$DIRECTORIO_CLAVES/ssh" "$DIRECTORIO_CLAVES/vpn"; do
  [[ ! -L "$carpeta" ]] || { echo "La carpeta privada no puede ser un enlace simbólico: $carpeta" >&2; return 1; }
  mkdir -p -- "$carpeta" && chmod 700 -- "$carpeta" || return
  if ((EUID==0)) && [[ "$usuario" != root ]]; then chown -- "$usuario:$(id -gn "$usuario")" "$carpeta" || return; fi
 done
 if [[ ! -e "$DIRECTORIO_CLAVES/.gitignore" && ! -L "$DIRECTORIO_CLAVES/.gitignore" ]]; then
  (umask 077; set -o noclobber; printf '*\n' > "$DIRECTORIO_CLAVES/.gitignore") || return
  if ((EUID==0)) && [[ "$usuario" != root ]]; then chown -- "$usuario:$(id -gn "$usuario")" "$DIRECTORIO_CLAVES/.gitignore" || return; fi
 fi
}

guardar_perfil_local () {
 local origen=$1 nombre=$2 destino usuario=${SUDO_USER:-$(id -un)}
 preparar_directorio_claves || return
 destino="$DIRECTORIO_CLAVES/vpn/$nombre.conf"
 [[ ! -L "$destino" ]] || { echo 'No se sobrescribirá un enlace en la carpeta privada.' >&2; return 1; }
 if [[ -e "$destino" ]] && ! cmp -s -- "$origen" "$destino"; then
  echo "Ya hay un perfil diferente en $destino. Se conserva sin cambios." >&2; return 1
 fi
 if [[ ! -e "$destino" ]]; then (umask 077; set -o noclobber; cat -- "$origen" > "$destino") || return; fi
 chmod 600 -- "$destino" || return
 if ((EUID==0)) && [[ "$usuario" != root ]]; then chown -- "$usuario:$(id -gn "$usuario")" "$destino" || return; fi
 printf 'Perfil personal guardado en %s\n' "$destino"
}

instalar_dependencias_vpn () {
 local -a paquetes=()
 command -v python3 >/dev/null || paquetes+=(python3)
 command -v wg >/dev/null || paquetes+=(wireguard-tools)
 command -v nmcli >/dev/null || paquetes+=(network-manager)
 command -v ssh >/dev/null || paquetes+=(openssh-client)
 command -v rsync >/dev/null || paquetes+=(rsync)
 command -v xdg-open >/dev/null || paquetes+=(xdg-utils)
 ((${#paquetes[@]})) || { echo 'Las herramientas de VPN ya están instaladas.'; return 0; }
 command -v apt-get >/dev/null || { echo 'La instalación automática requiere Deepin/Debian con APT.' >&2; return 1; }
 printf 'Se instalarán desde tus repositorios APT: %s\n' "${paquetes[*]}"
 confirmar_operacion '¿Instalar estas herramientas? Se solicitará la contraseña local de administración si hace falta.' || return 1
 vpn_admin apt-get update && vpn_admin apt-get install -y -- "${paquetes[@]}"
}

guardar_nombre_vpn () {
 comprobar_config_repositorios || return
 python3 - "$ARCHIVO_CONFIG" "$1" <<'PY'
from pathlib import Path
import os,sys,tempfile
p=Path(sys.argv[1]);name=sys.argv[2]
lines=[line for line in p.read_text().splitlines() if line.split('=',1)[0].strip()!='vpn-perfil']
fd,tmp=tempfile.mkstemp(prefix='.config-vpn-',dir=p.parent)
with os.fdopen(fd,'w') as f:f.write('\n'.join(lines)+'\nvpn-perfil='+name+'\n')
st=p.stat();os.chmod(tmp,0o600);os.chown(tmp,st.st_uid,st.st_gid);os.replace(tmp,p)
PY
}

usuario_local_vpn () {
 local usuario uid
 # This is an existing local desktop account, not the remote maintainer login.
 # Do not impose the remote username pattern or trust inherited SUDO_USER when
 # already running as an ordinary user.
 if ((EUID==0)); then usuario=${SUDO_USER:-root}; else usuario=$(id -un) || return 1; fi
 uid=$(id -u -- "$usuario" 2>/dev/null) || { echo 'No se ha podido identificar la cuenta local para la VPN.' >&2; return 1; }
 [[ "$uid" =~ ^[0-9]+$ ]] || { echo 'No se ha podido verificar el identificador de la cuenta local.' >&2; return 1; }
 ((uid!=0)) || { echo 'La cuenta local es root. Abre una terminal con tu usuario habitual y ejecuta el asistente sin sudo.' >&2; return 1; }
 # Delimiters/control characters cannot be embedded in a keyfile permission.
 if [[ -z "$usuario" || "$usuario" == *:* || "$usuario" == *';'* || "$usuario" == *\\* || "$usuario" =~ [[:cntrl:]] ]]; then
  echo 'La cuenta local tiene caracteres no admitidos por este importador de VPN. Consulta al administrador; no cambies ni borres tus claves.' >&2
  return 1
 fi
 printf '%s\n' "$usuario"
}

configurar_vpn () (
 umask 077
 local perfil=${1:-} nombre=${2:-RepoDeepines} usuario tmp uuid destino cargado=0
 usuario=$(usuario_local_vpn) || return 1
 [[ "$nombre" =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$ ]] || { echo 'Nombre de conexión inválido.' >&2; return 1; }
 if [[ -z "$perfil" ]]; then
  echo 'Indica el archivo .conf personal que te ha entregado el administrador de la VPN.'
  read -e -r -p 'Archivo: ' perfil || return 1
 fi
 [[ -f "$perfil" && -r "$perfil" ]] || { echo 'No se puede leer ese archivo.' >&2; return 1; }
 instalar_dependencias_vpn || return
 [[ $(LC_ALL=C nmcli -g RUNNING general 2>/dev/null) == running ]] || { echo 'NetworkManager no está activo. Revisa el servicio de red antes de continuar.' >&2; return 1; }
 if nmcli -g connection.uuid connection show "$nombre" >/dev/null 2>&1; then
  echo "Ya existe la conexión $nombre. Se conserva intacta; puedes conectarla desde el menú VPN." >&2
  return 1
 fi
 tmp=$(mktemp -d /tmp/deepines-vpn.XXXXXXXXXX) || return
 trap 'rm -rf -- "$tmp"' EXIT
 python3 - "$perfil" "$tmp" "$nombre" "$usuario" <<'PY'
import base64,configparser,ipaddress,json,os,re,sys,uuid
from pathlib import Path
source,tmp,name,user=sys.argv[1:];tmp=Path(tmp)
def require(ok,message):
 if not ok:raise ValueError(message)
def key(value):
 require(re.fullmatch(r'[A-Za-z0-9+/]{43}=',value) is not None,'Formato de clave WireGuard inválido')
 require(len(base64.b64decode(value,validate=True))==32,'Longitud de clave inválida')
 return value
try:
 raw=Path(source).read_text();require(len(raw)<=65536,'Perfil demasiado grande')
 c=configparser.ConfigParser(interpolation=None,strict=True);c.optionxform=str;c.read_string(raw)
 require(set(c.sections())=={'Interface','Peer'},'Se requiere exactamente Interface y un Peer')
 require(not c.defaults(),'No se admiten valores DEFAULT')
 interface,peer=c['Interface'],c['Peer']
 require(set(interface)<={'PrivateKey','Address','MTU','DNS'},'El perfil contiene opciones o comandos no admitidos')
 require(set(peer)<={'PublicKey','PresharedKey','AllowedIPs','Endpoint','PersistentKeepalive'},'El perfil contiene opciones no admitidas')
 private=key(interface['PrivateKey'].strip());public=key(peer['PublicKey'].strip())
 address=ipaddress.ip_interface(interface['Address'].strip());require(address.version==4 and address.ip.is_private and not address.ip.is_loopback,'Dirección del cliente inválida')
 networks=[ipaddress.ip_network(x.strip(),strict=True) for x in peer['AllowedIPs'].split(',')]
 require(0<len(networks)<=16,'Número de destinos inválido')
 spaces=[ipaddress.ip_network(x) for x in ['10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','192.0.2.0/24']]
 require(all(n.version==4 and n.prefixlen==32 and any(n.subnet_of(s) for s in spaces) for n in networks),'Este asistente admite destinos internos individuales /32; solicita el perfil para repositorios')
 endpoint=peer['Endpoint'].strip();match=re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9.-]*):(\d{1,5})',endpoint)
 require(match is not None and 1<=int(match.group(2))<=65535,'Endpoint inválido')
 mtu=int(interface.get('MTU','1380'));require(1280<=mtu<=1420,'MTU fuera del intervalo admitido 1280–1420')
 keepalive=int(peer.get('PersistentKeepalive','25'));require(0<=keepalive<=65535,'Keepalive inválido')
 uid=str(uuid.uuid4());ifname='dp'+uid.replace('-','')[:10]
 out=configparser.ConfigParser(interpolation=None);out.optionxform=str
 out['connection']={'id':name,'uuid':uid,'type':'wireguard','interface-name':ifname,'autoconnect':'false','permissions':f'user:{user}:;'}
 out['wireguard']={'private-key':private,'private-key-flags':'0','mtu':str(mtu),'peer-routes':'true'}
 out['wireguard-peer.'+public]={'endpoint':endpoint,'allowed-ips':';'.join(str(n) for n in networks)+';','persistent-keepalive':str(keepalive)}
 if peer.get('PresharedKey','').strip():out['wireguard-peer.'+public]['preshared-key']=key(peer['PresharedKey'].strip())
 out['ipv4']={'method':'manual','address1':str(address),'never-default':'true','ignore-auto-dns':'true'}
 out['ipv6']={'method':'disabled'}
 with (tmp/'profile.nmconnection').open('w') as f:out.write(f,space_around_delimiters=False)
 (tmp/'uuid').write_text(uid)
 print('Perfil validado. Servidor: '+endpoint+'; destinos: '+', '.join(str(n) for n in networks))
 print('Se mantendrán la ruta de Internet y los DNS habituales.')
except Exception as e:
 # Never echo parser exception text, which can contain lines with private keys.
 print('Perfil rechazado: revisa formato, claves y destinos con el administrador.',file=sys.stderr)
 raise SystemExit(1)
PY
 local rc=$?
 ((rc==0)) || return "$rc"
 guardar_perfil_local "$perfil" "$nombre" || return
 uuid=$(<"$tmp/uuid"); destino="/etc/NetworkManager/system-connections/deepines-$uuid.nmconnection"
 vpn_admin install -o root -g root -m 600 -- "$tmp/profile.nmconnection" "$destino" || return
 if ! vpn_admin nmcli connection load "$destino"; then
  vpn_admin rm -f -- "$destino"
  echo 'No se pudo cargar el perfil. No se ha modificado ninguna conexión anterior.' >&2
  return 1
 fi
 guardar_nombre_vpn "$nombre" || { echo "La conexión $nombre se creó, pero no pudo guardarse su nombre en $ARCHIVO_CONFIG." >&2; return 1; }
 echo "VPN preparada: $nombre. El perfil original y las conexiones existentes se conservan."
 echo 'Conéctala desde la opción VPN del asistente o desde los ajustes de red de Deepin.'
)

conectar_vpn () {
 local nombre=${1:-$VPN_PERFIL}
 command -v nmcli >/dev/null || { echo 'Configura primero la VPN con --configurar-vpn.' >&2; return 1; }
 vpn_admin nmcli --wait 30 connection up "$nombre" || return
 local interfaz datos intento
 interfaz=$(nmcli -g connection.interface-name connection show "$nombre") || return
 for intento in {1..8}; do
  datos=$(vpn_admin wg show "$interfaz" latest-handshakes) || return
  if awk 'NF>=2 && $2>0 {ok=1} END {exit !ok}' <<< "$datos"; then
   echo 'VPN conectada y comunicación con pfSense verificada.'
   return 0
  fi
  sleep 1
 done
 echo 'El perfil está activo pero no hay respuesta de pfSense. Comprueba Internet y que tu perfil esté autorizado.' >&2
 return 1
}

menu_vpn () {
 local opcion archivo
 echo "VPN: $VPN_PERFIL"
 printf '%s\n' '1) Preparar/importar perfil personal' '2) Conectar y comprobar' '3) Desconectar' '4) Ver estado' '5) Ver carpeta de claves' '6) Alta automática con código de invitación' 'C) Volver'
 read -r opcion || return 1
 case "$opcion" in
  1) configurar_vpn; obtener_lista_repo ;;
  2) conectar_vpn ;;
  3) vpn_admin nmcli connection down "$VPN_PERFIL" ;;
  4) nmcli -f GENERAL.STATE,GENERAL.DEVICES connection show "$VPN_PERFIL" ;;
  5) preparar_directorio_claves && printf '%s\n' "$DIRECTORIO_CLAVES" ;;
 esac
 enter_para_continuar
}

alta_python () {
 python3 - "$@" <<'DEEPINES_ENROLLMENT_PY'
"""Embedded in the standalone assistant. No network registration or secret export."""
import base64
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import stat
import struct
import subprocess
import sys
import uuid

USERS = {"fenoll", "car", "isaias", "xoas", "eli"}
POLICY = {
    "endpoint": "server2.marfen.es:51821",
    "server_wireguard_public_key": "sHAViR1R3ICIaIyS3I5nyI9FZnOB8FOajCyckj9DCiw=",
    "server_ssh_public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINDuhYHl/rBCSBrVRAr86dq3fBdKl9H5cOq/V1mI3jBO",
    "repository_address": "192.0.2.107",
    "allowed_ips": ["192.0.2.107/32"],
    "ssh_port": 22,
    "repository_number": 6,
}
REQUEST_FIELDS = {"format", "request_id", "username", "device", "ssh_public_key", "wireguard_public_key"}


class EnrollmentError(Exception):
    pass


def require(ok, message):
    if not ok:
        raise EnrollmentError(message)


def read_file(path, private=False, limit=32768):
    path = Path(path)
    fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd, "rb") as f:
        st = os.fstat(f.fileno())
        require(stat.S_ISREG(st.st_mode) and st.st_size <= limit, "Archivo inválido o demasiado grande.")
        if private:
            require(st.st_uid == os.getuid() and st.st_nlink == 1 and st.st_mode & 0o077 == 0,
                    "Revisa el propietario y los permisos de los archivos privados.")
        return f.read(limit + 1).decode("utf-8")


def directory(path):
    home = Path.home()
    require(home.is_dir() and not home.is_symlink() and home.stat().st_uid == os.getuid(), "HOME inválido.")
    rel = path.relative_to(home)
    current = home
    for part in rel.parts:
        current /= part
        require(not current.is_symlink(), "No se admiten enlaces en las carpetas de acceso.")
        current.mkdir(mode=0o700, exist_ok=True)
        require(current.is_dir() and current.stat().st_uid == os.getuid(), "Carpeta de otro propietario.")
    path.chmod(0o700)


def existing_equal(path, text):
    if path.exists() or path.is_symlink():
        require(read_file(path, private=True) == text, "Ya existe un archivo diferente; se conserva sin cambios: " + str(path))


def save_once(path, text):
    existing_equal(path, text)
    if not path.exists():
        fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(text)
            f.flush()
            os.fsync(f.fileno())


def wg_public(private):
    p = subprocess.run(["wg", "pubkey"], input=private, text=True, capture_output=True)
    require(p.returncode == 0, "La clave WireGuard local no es válida.")
    public = p.stdout.strip()
    require(re.fullmatch(r"[A-Za-z0-9+/]{43}=", public) is not None, "Clave WireGuard inválida.")
    return public


def ssh_blob(public):
    parts = public.strip().split()
    require(len(parts) == 2 and parts[0] == "ssh-ed25519", "Se requiere una clave pública SSH Ed25519.")
    blob = base64.b64decode(parts[1], validate=True)
    require(len(blob) == 51 and blob[:19] == b"\0\0\0\x0bssh-ed25519\0\0\0\x20", "Clave pública SSH inválida.")
    return blob


def ssh_public_from_private(path):
    # OpenSSH stores the public half outside the encrypted private payload.
    # This verifies an existing pair without asking for its passphrase again.
    text = read_file(path, private=True)
    lines = text.strip().splitlines()
    require(lines[0] == "-----BEGIN OPENSSH PRIVATE KEY-----" and lines[-1] == "-----END OPENSSH PRIVATE KEY-----", "Formato SSH privado no admitido.")
    blob = base64.b64decode("".join(lines[1:-1]), validate=True)
    require(blob.startswith(b"openssh-key-v1\0"), "Clave SSH inválida.")
    offset = 15

    def field():
        nonlocal offset
        size = struct.unpack_from(">I", blob, offset)[0]
        offset += 4
        require(size <= len(blob) - offset, "Clave SSH truncada.")
        value = blob[offset:offset + size]
        offset += size
        return value

    field(); field(); field()
    require(struct.unpack_from(">I", blob, offset)[0] == 1, "Número de claves SSH inválido.")
    offset += 4
    public = "ssh-ed25519 " + base64.b64encode(field()).decode()
    ssh_blob(public)
    return public


def public_json(data):
    return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"


def validate_request(data):
    require(isinstance(data, dict) and set(data) == REQUEST_FIELDS, "Solicitud con campos no admitidos.")
    require(data["format"] == "deepines-access-request-v1", "Versión de solicitud inválida.")
    require(data["username"] in USERS, "Mantenedor no reconocido.")
    require(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,23}", data["device"]) is not None, "Nombre de equipo inválido.")
    require(str(uuid.UUID(data["request_id"])) == data["request_id"], "Identificador inválido.")
    ssh_blob(data["ssh_public_key"])
    public = data["wireguard_public_key"]
    require(re.fullmatch(r"[A-Za-z0-9+/]{43}=", public) is not None and len(base64.b64decode(public, validate=True)) == 32, "Clave pública VPN inválida.")


def local_paths():
    base = Path.home() / ".config/deepines"
    for p in (base, base / "ssh", base / "vpn", base / "altas"):
        directory(p)
    save_once(base / ".gitignore", "*\n")
    return base


def prepare(username, device, work):
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    require(username in USERS, "Elige fenoll, car, isaias, xoas o eli.")
    require(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,23}", device) is not None, "Usa 1–24 letras minúsculas, números o guiones para el equipo.")
    base = local_paths()
    request_path = base / "altas/solicitud.json"
    previous = json.loads(read_file(request_path, private=True)) if request_path.exists() else None
    if previous:
        validate_request(previous)
        require((previous["username"], previous["device"]) == (username, device), "Ya hay una solicitud para otro usuario o equipo. Se conservan sus claves.")
    ssh = base / "ssh/id_ed25519"
    wg = base / "vpn/client.key"
    # Do not generate an unrelated VPN identity alongside an older configured profile.
    require(wg.exists() or not list((base / "vpn").glob("*.conf")), "Ya tienes un perfil VPN anterior. Consulta al administrador antes de crear otro acceso.")
    if previous:
        require(ssh.exists() and wg.exists(), "Falta una clave de la solicitud anterior. No se generará otra identidad automáticamente.")
    if not ssh.exists():
        require(not ssh.is_symlink() and not ssh.with_suffix(".pub").exists() and not ssh.with_suffix(".pub").is_symlink(), "Ya hay un archivo SSH; no se sobrescribirá.")
        print("Crea una frase de paso para proteger tu clave SSH; solo la usarás en este ordenador.", flush=True)
        with open("/dev/tty", "rb", buffering=0) as tty:
            p = subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-a", "64", "-f", str(ssh), "-C", "deepines-" + username + "-" + device], stdin=tty)
        require(p.returncode == 0, "No se ha podido generar la clave SSH.")
    public_ssh = ssh_public_from_private(ssh)
    pub_path = ssh.with_suffix(".pub")
    if pub_path.exists():
        require(" ".join(read_file(pub_path).split()[:2]) == public_ssh, "La clave pública SSH no corresponde a la privada.")
    else:
        save_once(pub_path, public_ssh + "\n")
    if not wg.exists():
        p = subprocess.run(["wg", "genkey"], capture_output=True, text=True)
        require(p.returncode == 0, "No se ha podido generar la clave VPN.")
        wg_public(p.stdout)
        save_once(wg, p.stdout)
    public_wg = wg_public(read_file(wg, private=True))
    save_once(base / "vpn/client.pub", public_wg + "\n")
    data = {"format": "deepines-access-request-v1", "request_id": previous["request_id"] if previous else str(uuid.uuid4()),
            "username": username, "device": device, "ssh_public_key": public_ssh, "wireguard_public_key": public_wg}
    validate_request(data)
    text = public_json(data)
    existing_equal(request_path, text)
    export_dir = Path(work) / "altas"
    directory(export_dir)
    export = export_dir / ("alta-" + username + "-" + device + ".json")
    existing_equal(export, text)
    save_once(request_path, text)
    save_once(export, text)
    print("Solicitud lista para entregar: " + str(export))
    print("Contiene únicamente usuario, equipo, identificador y las dos claves públicas.")
    print("Envíala por el canal acordado con el administrador. No requiere conexión VPN.")
    print("No envíes la carpeta privada ni archivos .key, id_ed25519 o perfiles .conf.")


def validate_reply(reply, request):
    require(isinstance(reply, dict) and set(reply) == {"format", "request", "vpn_address", "policy"}, "Respuesta con campos no admitidos.")
    require(reply["format"] == "deepines-access-approved-v1", "Formato de respuesta inválido.")
    validate_request(reply["request"])
    require(reply["request"] == request, "La respuesta no corresponde a la solicitud y a las claves de este equipo.")
    require(reply["policy"] == POLICY, "El servidor, sus claves o los destinos no coinciden con este asistente.")
    address = ipaddress.ip_interface(reply["vpn_address"])
    require(str(address) == reply["vpn_address"] and address.version == 4 and address.network.prefixlen == 32
            and address.ip in ipaddress.ip_network("10.79.113.0/24") and 3 <= int(address.ip) % 256 <= 254,
            "Dirección VPN individual inválida o reservada.")


def response(path, work, commit=False):
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    base = local_paths()
    request = json.loads(read_file(base / "altas/solicitud.json", private=True))
    validate_request(request)
    reply = json.loads(read_file(path))
    validate_reply(reply, request)
    require(ssh_public_from_private(base / "ssh/id_ed25519") == request["ssh_public_key"], "La clave SSH local ha cambiado.")
    private_wg = read_file(base / "vpn/client.key", private=True).strip()
    require(wg_public(private_wg) == request["wireguard_public_key"], "La clave VPN local ha cambiado.")
    name = "RepoDeepines-" + request["username"] + "-" + request["device"]
    # -F keeps unrelated ~/.ssh/config settings and production aliases untouched.
    ssh_config = ("Host repo-deepines\n"
        "    HostName 192.0.2.107\n    Port 22\n    User " + request["username"] + "\n"
        "    IdentityFile ~/.config/deepines/ssh/id_ed25519\n"
        "    UserKnownHostsFile ~/.config/deepines/ssh/known_hosts\n"
        "    GlobalKnownHostsFile /dev/null\n    IdentitiesOnly yes\n"
        "    StrictHostKeyChecking yes\n    PasswordAuthentication no\n"
        "    KbdInteractiveAuthentication no\n    ForwardAgent no\n    ClearAllForwardings yes\n")
    vpn = ("[Interface]\nPrivateKey = " + private_wg + "\nAddress = " + reply["vpn_address"] + "\nMTU = 1380\n\n"
           "[Peer]\nPublicKey = " + POLICY["server_wireguard_public_key"] + "\nEndpoint = " + POLICY["endpoint"] + "\n"
           "AllowedIPs = 192.0.2.107/32\nPersistentKeepalive = 25\n")
    config = ("servidor-ssh=repo-deepines\nvpn-perfil=" + name + "\nnombre-usuario=" + request["username"]
              + "\nvps_repo=Deepines_6 22 6\ngit_repo=\n")
    files = {base / "ssh/config": ssh_config,
             base / "ssh/known_hosts": "192.0.2.107 " + POLICY["server_ssh_public_key"] + "\n",
             base / ("vpn/" + name + ".conf"): vpn,
             base / "altas/respuesta.json": public_json(reply),
             Path(work) / ".config": config}
    # A pristine template may be replaced; configured workflows never are.
    cfg = Path(work) / ".config"
    if cfg.exists():
        old = read_file(cfg, private=True)
        values = {}
        for line in old.splitlines():
            line = line.split("#", 1)[0].strip()
            if "=" in line:
                k, v = line.split("=", 1)
                require(k.strip() not in values, "Hay campos repetidos en la configuración local.")
                values[k.strip()] = v.strip()
        empty = set(values) <= {"servidor-ssh", "vpn-perfil", "nombre-usuario", "vps_repo", "git_repo"} and all(not v for k, v in values.items() if k != "vpn-perfil")
        expected = dict(line.split("=", 1) for line in config.splitlines())
        same = {k: v for k, v in values.items() if k != "git_repo"} == {k: v for k, v in expected.items() if k != "git_repo"}
        require(same or empty, "Ya existe una configuración de repositorio diferente. Se conserva intacta.")
        if same:
            files[cfg] = old
    for target, text in files.items():
        if target != cfg:
            existing_equal(target, text)
    if commit:
        for target, text in files.items():
            directory(target.parent)
            if target == cfg and cfg.exists() and read_file(cfg, private=True) != text:
                save_once(base / "altas/config-antes-del-alta", read_file(cfg, private=True))
                # Replace only the checked empty template, through an exclusive temporary file.
                tmp = cfg.with_name(".config-alta-" + uuid.uuid4().hex)
                save_once(tmp, text)
                os.replace(tmp, cfg)
            else:
                save_once(target, text)
        print(name)
    else:
        print("Respuesta comprobada para " + request["username"] + " / " + request["device"] + ".")
        print("VPN " + reply["vpn_address"] + "; destino autorizado " + POLICY["repository_address"] + ".")


def main():
    os.umask(0o077)
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    base = local_paths()
    fd = os.open(base / "altas/.lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
    lock = os.fdopen(fd, "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        raise EnrollmentError("Ya hay otra preparación de acceso en curso en este equipo.")
    action = sys.argv[1]
    if action == "prepare":
        prepare(*sys.argv[2:])
    elif action in {"check", "apply"}:
        response(*sys.argv[2:], commit=action == "apply")
    else:
        raise EnrollmentError("Operación desconocida.")


if __name__ == "__main__":
    try:
        main()
    except EnrollmentError as e:
        # Only controlled messages, never parser/subprocess exception details.
        print("No se ha completado el alta: " + str(e), file=sys.stderr)
        raise SystemExit(1)
    except Exception:
        print("No se ha completado el alta. Revisa archivos, permisos y herramientas locales con el administrador.", file=sys.stderr)
        raise SystemExit(1)

DEEPINES_ENROLLMENT_PY
}

alta_https_python () {
 python3 - "$@" <<'DEEPINES_HTTPS_PY'
"""Embedded in the standalone assistant. No network registration or secret export."""
import base64
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import stat
import struct
import subprocess
import sys
import uuid

USERS = {"fenoll", "car", "isaias", "xoas", "eli"}
POLICY = {
    "endpoint": "server2.marfen.es:51821",
    "server_wireguard_public_key": "sHAViR1R3ICIaIyS3I5nyI9FZnOB8FOajCyckj9DCiw=",
    "server_ssh_public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINDuhYHl/rBCSBrVRAr86dq3fBdKl9H5cOq/V1mI3jBO",
    "repository_address": "192.0.2.107",
    "allowed_ips": ["192.0.2.107/32"],
    "ssh_port": 22,
    "repository_number": 6,
}
REQUEST_FIELDS = {"format", "request_id", "username", "device", "ssh_public_key", "wireguard_public_key"}


class EnrollmentError(Exception):
    pass


def require(ok, message):
    if not ok:
        raise EnrollmentError(message)


def read_file(path, private=False, limit=32768):
    path = Path(path)
    fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
    with os.fdopen(fd, "rb") as f:
        st = os.fstat(f.fileno())
        require(stat.S_ISREG(st.st_mode) and st.st_size <= limit, "Archivo inválido o demasiado grande.")
        if private:
            require(st.st_uid == os.getuid() and st.st_nlink == 1 and st.st_mode & 0o077 == 0,
                    "Revisa el propietario y los permisos de los archivos privados.")
        return f.read(limit + 1).decode("utf-8")


def directory(path):
    home = Path.home()
    require(home.is_dir() and not home.is_symlink() and home.stat().st_uid == os.getuid(), "HOME inválido.")
    rel = path.relative_to(home)
    current = home
    for part in rel.parts:
        current /= part
        require(not current.is_symlink(), "No se admiten enlaces en las carpetas de acceso.")
        current.mkdir(mode=0o700, exist_ok=True)
        require(current.is_dir() and current.stat().st_uid == os.getuid(), "Carpeta de otro propietario.")
    path.chmod(0o700)


def existing_equal(path, text):
    if path.exists() or path.is_symlink():
        require(read_file(path, private=True) == text, "Ya existe un archivo diferente; se conserva sin cambios: " + str(path))


def save_once(path, text):
    existing_equal(path, text)
    if not path.exists():
        fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
        with os.fdopen(fd, "w") as f:
            f.write(text)
            f.flush()
            os.fsync(f.fileno())


def wg_public(private):
    p = subprocess.run(["wg", "pubkey"], input=private, text=True, capture_output=True)
    require(p.returncode == 0, "La clave WireGuard local no es válida.")
    public = p.stdout.strip()
    require(re.fullmatch(r"[A-Za-z0-9+/]{43}=", public) is not None, "Clave WireGuard inválida.")
    return public


def ssh_blob(public):
    parts = public.strip().split()
    require(len(parts) == 2 and parts[0] == "ssh-ed25519", "Se requiere una clave pública SSH Ed25519.")
    blob = base64.b64decode(parts[1], validate=True)
    require(len(blob) == 51 and blob[:19] == b"\0\0\0\x0bssh-ed25519\0\0\0\x20", "Clave pública SSH inválida.")
    return blob


def ssh_public_from_private(path):
    # OpenSSH stores the public half outside the encrypted private payload.
    # This verifies an existing pair without asking for its passphrase again.
    text = read_file(path, private=True)
    lines = text.strip().splitlines()
    require(lines[0] == "-----BEGIN OPENSSH PRIVATE KEY-----" and lines[-1] == "-----END OPENSSH PRIVATE KEY-----", "Formato SSH privado no admitido.")
    blob = base64.b64decode("".join(lines[1:-1]), validate=True)
    require(blob.startswith(b"openssh-key-v1\0"), "Clave SSH inválida.")
    offset = 15

    def field():
        nonlocal offset
        size = struct.unpack_from(">I", blob, offset)[0]
        offset += 4
        require(size <= len(blob) - offset, "Clave SSH truncada.")
        value = blob[offset:offset + size]
        offset += size
        return value

    field(); field(); field()
    require(struct.unpack_from(">I", blob, offset)[0] == 1, "Número de claves SSH inválido.")
    offset += 4
    public = "ssh-ed25519 " + base64.b64encode(field()).decode()
    ssh_blob(public)
    return public


def public_json(data):
    return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n"


def validate_request(data):
    require(isinstance(data, dict) and set(data) == REQUEST_FIELDS, "Solicitud con campos no admitidos.")
    require(data["format"] == "deepines-access-request-v1", "Versión de solicitud inválida.")
    require(data["username"] in USERS, "Mantenedor no reconocido.")
    require(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,23}", data["device"]) is not None, "Nombre de equipo inválido.")
    require(str(uuid.UUID(data["request_id"])) == data["request_id"], "Identificador inválido.")
    ssh_blob(data["ssh_public_key"])
    public = data["wireguard_public_key"]
    require(re.fullmatch(r"[A-Za-z0-9+/]{43}=", public) is not None and len(base64.b64decode(public, validate=True)) == 32, "Clave pública VPN inválida.")


def local_paths():
    base = Path.home() / ".config/deepines"
    for p in (base, base / "ssh", base / "vpn", base / "altas"):
        directory(p)
    save_once(base / ".gitignore", "*\n")
    return base


def prepare(username, device, work):
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    require(username in USERS, "Elige fenoll, car, isaias, xoas o eli.")
    require(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,23}", device) is not None, "Usa 1–24 letras minúsculas, números o guiones para el equipo.")
    base = local_paths()
    request_path = base / "altas/solicitud.json"
    previous = json.loads(read_file(request_path, private=True)) if request_path.exists() else None
    if previous:
        validate_request(previous)
        require((previous["username"], previous["device"]) == (username, device), "Ya hay una solicitud para otro usuario o equipo. Se conservan sus claves.")
    ssh = base / "ssh/id_ed25519"
    wg = base / "vpn/client.key"
    # Do not generate an unrelated VPN identity alongside an older configured profile.
    require(wg.exists() or not list((base / "vpn").glob("*.conf")), "Ya tienes un perfil VPN anterior. Consulta al administrador antes de crear otro acceso.")
    if previous:
        require(ssh.exists() and wg.exists(), "Falta una clave de la solicitud anterior. No se generará otra identidad automáticamente.")
    if not ssh.exists():
        require(not ssh.is_symlink() and not ssh.with_suffix(".pub").exists() and not ssh.with_suffix(".pub").is_symlink(), "Ya hay un archivo SSH; no se sobrescribirá.")
        print("Crea una frase de paso para proteger tu clave SSH; solo la usarás en este ordenador.", flush=True)
        with open("/dev/tty", "rb", buffering=0) as tty:
            p = subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-a", "64", "-f", str(ssh), "-C", "deepines-" + username + "-" + device], stdin=tty)
        require(p.returncode == 0, "No se ha podido generar la clave SSH.")
    public_ssh = ssh_public_from_private(ssh)
    pub_path = ssh.with_suffix(".pub")
    if pub_path.exists():
        require(" ".join(read_file(pub_path).split()[:2]) == public_ssh, "La clave pública SSH no corresponde a la privada.")
    else:
        save_once(pub_path, public_ssh + "\n")
    if not wg.exists():
        p = subprocess.run(["wg", "genkey"], capture_output=True, text=True)
        require(p.returncode == 0, "No se ha podido generar la clave VPN.")
        wg_public(p.stdout)
        save_once(wg, p.stdout)
    public_wg = wg_public(read_file(wg, private=True))
    save_once(base / "vpn/client.pub", public_wg + "\n")
    data = {"format": "deepines-access-request-v1", "request_id": previous["request_id"] if previous else str(uuid.uuid4()),
            "username": username, "device": device, "ssh_public_key": public_ssh, "wireguard_public_key": public_wg}
    validate_request(data)
    text = public_json(data)
    existing_equal(request_path, text)
    export_dir = Path(work) / "altas"
    directory(export_dir)
    export = export_dir / ("alta-" + username + "-" + device + ".json")
    existing_equal(export, text)
    save_once(request_path, text)
    save_once(export, text)
    print("Solicitud lista para entregar: " + str(export))
    print("Contiene únicamente usuario, equipo, identificador y las dos claves públicas.")
    print("Envíala por el canal acordado con el administrador. No requiere conexión VPN.")
    print("No envíes la carpeta privada ni archivos .key, id_ed25519 o perfiles .conf.")


def validate_reply(reply, request):
    require(isinstance(reply, dict) and set(reply) == {"format", "request", "vpn_address", "policy"}, "Respuesta con campos no admitidos.")
    require(reply["format"] == "deepines-access-approved-v1", "Formato de respuesta inválido.")
    validate_request(reply["request"])
    require(reply["request"] == request, "La respuesta no corresponde a la solicitud y a las claves de este equipo.")
    require(reply["policy"] == POLICY, "El servidor, sus claves o los destinos no coinciden con este asistente.")
    address = ipaddress.ip_interface(reply["vpn_address"])
    require(str(address) == reply["vpn_address"] and address.version == 4 and address.network.prefixlen == 32
            and address.ip in ipaddress.ip_network("10.79.113.0/24") and 3 <= int(address.ip) % 256 <= 254,
            "Dirección VPN individual inválida o reservada.")


def response(path, work, commit=False):
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    base = local_paths()
    request = json.loads(read_file(base / "altas/solicitud.json", private=True))
    validate_request(request)
    reply = json.loads(read_file(path))
    validate_reply(reply, request)
    require(ssh_public_from_private(base / "ssh/id_ed25519") == request["ssh_public_key"], "La clave SSH local ha cambiado.")
    private_wg = read_file(base / "vpn/client.key", private=True).strip()
    require(wg_public(private_wg) == request["wireguard_public_key"], "La clave VPN local ha cambiado.")
    name = "RepoDeepines-" + request["username"] + "-" + request["device"]
    # -F keeps unrelated ~/.ssh/config settings and production aliases untouched.
    ssh_config = ("Host repo-deepines\n"
        "    HostName 192.0.2.107\n    Port 22\n    User " + request["username"] + "\n"
        "    IdentityFile ~/.config/deepines/ssh/id_ed25519\n"
        "    UserKnownHostsFile ~/.config/deepines/ssh/known_hosts\n"
        "    GlobalKnownHostsFile /dev/null\n    IdentitiesOnly yes\n"
        "    StrictHostKeyChecking yes\n    PasswordAuthentication no\n"
        "    KbdInteractiveAuthentication no\n    ForwardAgent no\n    ClearAllForwardings yes\n")
    vpn = ("[Interface]\nPrivateKey = " + private_wg + "\nAddress = " + reply["vpn_address"] + "\nMTU = 1380\n\n"
           "[Peer]\nPublicKey = " + POLICY["server_wireguard_public_key"] + "\nEndpoint = " + POLICY["endpoint"] + "\n"
           "AllowedIPs = 192.0.2.107/32\nPersistentKeepalive = 25\n")
    config = ("servidor-ssh=repo-deepines\nvpn-perfil=" + name + "\nnombre-usuario=" + request["username"]
              + "\nvps_repo=Deepines_6 22 6\ngit_repo=\n")
    files = {base / "ssh/config": ssh_config,
             base / "ssh/known_hosts": "192.0.2.107 " + POLICY["server_ssh_public_key"] + "\n",
             base / ("vpn/" + name + ".conf"): vpn,
             base / "altas/respuesta.json": public_json(reply),
             Path(work) / ".config": config}
    # A pristine template may be replaced; configured workflows never are.
    cfg = Path(work) / ".config"
    if cfg.exists():
        old = read_file(cfg, private=True)
        values = {}
        for line in old.splitlines():
            line = line.split("#", 1)[0].strip()
            if "=" in line:
                k, v = line.split("=", 1)
                require(k.strip() not in values, "Hay campos repetidos en la configuración local.")
                values[k.strip()] = v.strip()
        empty = set(values) <= {"servidor-ssh", "vpn-perfil", "nombre-usuario", "vps_repo", "git_repo"} and all(not v for k, v in values.items() if k != "vpn-perfil")
        expected = dict(line.split("=", 1) for line in config.splitlines())
        same = {k: v for k, v in values.items() if k != "git_repo"} == {k: v for k, v in expected.items() if k != "git_repo"}
        require(same or empty, "Ya existe una configuración de repositorio diferente. Se conserva intacta.")
        if same:
            files[cfg] = old
    for target, text in files.items():
        if target != cfg:
            existing_equal(target, text)
    if commit:
        for target, text in files.items():
            directory(target.parent)
            if target == cfg and cfg.exists() and read_file(cfg, private=True) != text:
                save_once(base / "altas/config-antes-del-alta", read_file(cfg, private=True))
                # Replace only the checked empty template, through an exclusive temporary file.
                tmp = cfg.with_name(".config-alta-" + uuid.uuid4().hex)
                save_once(tmp, text)
                os.replace(tmp, cfg)
            else:
                save_once(target, text)
        print(name)
    else:
        print("Respuesta comprobada para " + request["username"] + " / " + request["device"] + ".")
        print("VPN " + reply["vpn_address"] + "; destino autorizado " + POLICY["repository_address"] + ".")


def main():
    os.umask(0o077)
    require(os.getuid() != 0, "Ejecuta el asistente con tu usuario habitual, sin sudo.")
    base = local_paths()
    fd = os.open(base / "altas/.lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
    lock = os.fdopen(fd, "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        raise EnrollmentError("Ya hay otra preparación de acceso en curso en este equipo.")
    action = sys.argv[1]
    if action == "prepare":
        prepare(*sys.argv[2:])
    elif action in {"check", "apply"}:
        response(*sys.argv[2:], commit=action == "apply")
    else:
        raise EnrollmentError("Operación desconocida.")


"""HTTPS exchange for enrollment. Secrets are scoped to one invitation/request."""
import hashlib
import json
import os
from pathlib import Path
import secrets
import ssl
import sys
import time
import urllib.error
import urllib.request
import uuid

# Set after verifying the public hostname and certificate for the receiver.
RECEIVER = 'https://altas.deepines.com'


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise EnrollmentError('El receptor ha respondido con una redirección. Consulta al administrador.')


def private_replace(path, data):
    if path.exists() or path.is_symlink():
        read_file(path, private=True)
    temporary = path.with_name('.https-' + uuid.uuid4().hex)
    save_once(temporary, public_json(data))
    os.replace(temporary, path)


def exchange(method, path, token, data=None, receipt=None):
    require(isinstance(token, str) and len(token) == 43, 'Credencial de alta inválida.')
    headers = {'Accept':'application/json','Authorization':'Bearer ' + token}
    if receipt:
        headers['X-Deepines-Receipt'] = receipt
    body = None if data is None else public_json(data).encode()
    if body is not None:
        headers['Content-Type'] = 'application/json'
    context = ssl.create_default_context()
    context.minimum_version = ssl.TLSVersion.TLSv1_2
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=context))
    req = urllib.request.Request(RECEIVER + path, data=body, headers=headers, method=method)
    try:
        with opener.open(req, timeout=20) as res:
            require(res.headers.get_content_type() == 'application/json', 'Respuesta del receptor no válida.')
            raw = res.read(32769)
            require(len(raw) <= 32768, 'Respuesta del receptor demasiado grande.')
            return json.loads(raw)
    except urllib.error.HTTPError as e:
        messages = {403:'Invitación no válida, caducada o de otro usuario.',404:'Solicitud no disponible para este equipo.',
                    409:'La invitación ya está usada o hay un conflicto; contacta con el administrador.',
                    410:'La solicitud ha caducado; contacta con el administrador.',429:'Demasiados intentos; vuelve a probar más tarde.'}
        raise EnrollmentError(messages.get(e.code,'El receptor no pudo completar la solicitud. No se han sustituido tus claves.'))
    except (urllib.error.URLError, TimeoutError, OSError):
        raise EnrollmentError('No se puede comunicar con el receptor mediante HTTPS verificado. Comprueba Internet y vuelve a intentarlo.')


def load_invitation(path):
    data = json.loads(read_file(path))
    require(isinstance(data, dict) and set(data) == {'format','username','endpoint','invitation','expires'}, 'Formato de invitación no válido.')
    require(data['format'] == 'deepines-invitation-v1' and data['username'] in USERS, 'Invitación de usuario no válida.')
    require(data['endpoint'] == RECEIVER, 'La invitación no corresponde al receptor configurado en el asistente.')
    require(isinstance(data['expires'], int) and data['expires'] > time.time(), 'La invitación ha caducado.')
    require(isinstance(data['invitation'], str) and re.fullmatch(r'[A-Za-z0-9_-]{43}', data['invitation']) is not None, 'Código de invitación no válido.')
    return data


def send_request(path):
    invitation = load_invitation(path)
    base = local_paths()
    data = json.loads(read_file(base / 'altas/solicitud.json', private=True))
    validate_request(data)
    require(data['username'] == invitation['username'], 'La invitación pertenece a otro mantenedor.')
    require(ssh_public_from_private(base / 'ssh/id_ed25519') == data['ssh_public_key'], 'La clave SSH local ha cambiado.')
    require(wg_public(read_file(base / 'vpn/client.key', private=True)) == data['wireguard_public_key'], 'La clave VPN local ha cambiado.')
    state_file = base / 'altas/https.json'
    if state_file.exists():
        state = json.loads(read_file(state_file, private=True))
        require(state['endpoint'] == RECEIVER and state['request_id'] == data['request_id'], 'Ya existe otra solicitud HTTPS en este equipo.')
        require(state['invitation_digest'] == hashlib.sha256(invitation['invitation'].encode()).hexdigest(), 'Ya hay una solicitud enviada con otra invitación.')
    else:
        state = {'endpoint':RECEIVER,'request_id':data['request_id'],'receipt':secrets.token_urlsafe(32),
                 'invitation_digest':hashlib.sha256(invitation['invitation'].encode()).hexdigest(),'sent':False}
        save_once(state_file, public_json(state))
    result = exchange('POST','/v1/requests',invitation['invitation'],data,state['receipt'])
    require(isinstance(result, dict) and result.get('request_id') == data['request_id'] and result.get('state') in {'pending','approved','rejected'}, 'Respuesta de recepción no válida.')
    state['sent'] = True
    private_replace(state_file, state)
    print('Solicitud entregada. El administrador revisará tu acceso.')
    print('Puedes cerrar el asistente y volver a «Recoger respuesta» más tarde.')


def collect_response():
    base = local_paths()
    state = json.loads(read_file(base / 'altas/https.json', private=True))
    data = json.loads(read_file(base / 'altas/solicitud.json', private=True))
    validate_request(data)
    require(state['endpoint'] == RECEIVER and state['request_id'] == data['request_id'], 'Estado HTTPS de otro equipo o receptor.')
    result = exchange('GET','/v1/requests/' + data['request_id'],state['receipt'])
    require(isinstance(result, dict) and result.get('request_id') == data['request_id'], 'Respuesta de otro equipo.')
    if result.get('state') == 'pending':
        print('Tu solicitud está pendiente de aprobación. Tus claves se conservan.'); return 3
    require(result.get('state') == 'approved', 'Solicitud cerrada. Consulta al administrador.')
    validate_reply(result.get('response'),data)
    save_once(base / 'altas/respuesta-recibida.json', public_json(result['response']))
    print('Respuesta recibida y comprobada. Ahora se preparará tu conexión.')
    return 0


def https_main():
    os.umask(0o077)
    require(os.getuid() != 0, 'Ejecuta el asistente con tu usuario habitual, sin sudo.')
    base = local_paths()
    fd = os.open(base / 'altas/.https-lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
    with os.fdopen(fd,'w') as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            raise EnrollmentError('Ya hay otra operación HTTPS de alta en curso.')
        action = sys.argv[1]
        if action == 'invitation-user':
            print(load_invitation(sys.argv[2])['username'])
        elif action == 'send':
            send_request(sys.argv[2])
        elif action == 'collect':
            return collect_response()
        else:
            raise EnrollmentError('Operación HTTPS no admitida.')
    return 0


if __name__ == '__main__':
    try:
        raise SystemExit(https_main())
    except EnrollmentError as e:
        print(str(e),file=sys.stderr);raise SystemExit(1)
    except Exception:
        print('No se completó el intercambio HTTPS. Revisa los archivos locales con el administrador; no envíes archivos privados.',file=sys.stderr)
        raise SystemExit(1)

DEEPINES_HTTPS_PY
}
# Enrollment always runs as the maintainer, with private keys staying on this PC.
preparar_mi_acceso () {
 local usuario=${1:-} equipo=${2:-}
 ((EUID!=0)) || { echo 'Ejecuta el asistente con tu usuario habitual, sin sudo.' >&2; return 1; }
 if [[ -z "$usuario" ]]; then
  echo 'Mantenedores: fenoll, car, isaias, xoas, eli'
  read -r -p 'Tu usuario: ' usuario || return 1
 fi
 if [[ -z "$equipo" ]]; then
  read -r -p 'Nombre del equipo (por ejemplo portatil, sin espacios ni tildes): ' equipo || return 1
 fi
 instalar_dependencias_vpn || return
 alta_python prepare "$usuario" "$equipo" "$CARPETA_TRABAJO"
}

importar_mi_acceso () {
 local archivo=${1:-} nombre perfil
 ((EUID!=0)) || { echo 'Ejecuta el asistente con tu usuario habitual, sin sudo.' >&2; return 1; }
 if [[ -z "$archivo" ]]; then
  read -e -r -p 'Archivo de respuesta entregado por el administrador: ' archivo || return 1
 fi
 instalar_dependencias_vpn || return
 alta_python check "$archivo" "$CARPETA_TRABAJO" || return
 confirmar_operacion '¿Importar este acceso recibido del administrador?' || return 1
 nombre=$(alta_python apply "$archivo" "$CARPETA_TRABAJO") || return
 perfil="$DIRECTORIO_CLAVES/vpn/$nombre.conf"
 if ! nmcli -g connection.uuid connection show "$nombre" >/dev/null 2>&1; then
  configurar_vpn "$perfil" "$nombre" || return
 else
  echo "La conexión $nombre ya existe. Se conserva sin cambios."
 fi
 obtener_lista_repo || return
 configurar_opciones_ssh
 echo 'Acceso preparado. Elige «Conectar y comprobar mi acceso» para probar VPN y SSH.'
}

configurar_opciones_ssh () {
 SSH_OPTS=(-o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2)
 if [[ ( -f "$HOME/.config/deepines/altas/respuesta.json" || -f "$HOME/.config/deepines/altas/automatico-aprobado.json" ) && "$SERVIDOR" == repo-deepines ]]; then
  SSH_OPTS=(-F "$HOME/.config/deepines/ssh/config" "${SSH_OPTS[@]}")
 fi
}

comprobar_mi_acceso () {
 comprobar_config_repositorios && obtener_lista_repo || return
 conectar_vpn "$VPN_PERFIL" || return
 if [[ -f "$DIRECTORIO_CLAVES/altas/respuesta.json" ]]; then
  if [[ -z ${SSH_AUTH_SOCK:-} ]]; then
   eval "$(ssh-agent -s)" >/dev/null || return
   # This agent is confined to this assistant process and removed on exit.
   trap 'ssh-agent -k >/dev/null 2>&1' EXIT
  fi
  echo 'Desbloquea tu clave SSH local si se solicita su frase de paso.'
  ssh-add -t 8h "$DIRECTORIO_CLAVES/ssh/id_ed25519" || return
 fi
 PUERTO_SELECCIONADO=22
 comprobar_acceso_ssh || return
 echo 'VPN y acceso SSH verificados. Ya puedes usar el asistente de paquetes.'
}

menu_alta () {
 local opcion
 printf '%s\n' 'Acceso de mantenedores' '1) Preparar mi acceso con el método anterior' '2) Importar respuesta del administrador' '3) Conectar y comprobar mi acceso' '4) Solicitar acceso por Internet con mi invitación' '5) Recoger respuesta y preparar la conexión' 'C) Volver'
 read -r opcion || return 1
 case "$opcion" in
  6) alta_automatica ;;
  1) preparar_mi_acceso ;;
  2) importar_mi_acceso ;;
  3) comprobar_mi_acceso ;;
  4) solicitar_mi_acceso ;;
  5) recoger_mi_acceso ;;
 esac
}

solicitar_mi_acceso () {
 local archivo=${1:-} usuario
 ((EUID!=0)) || { echo 'Ejecuta el asistente con tu usuario habitual, sin sudo.' >&2; return 1; }
 [[ -n "$archivo" ]] || read -e -r -p 'Archivo de invitación personal recibido del administrador: ' archivo || return 1
 instalar_dependencias_vpn || return
 usuario=$(alta_https_python invitation-user "$archivo") || return
 if [[ ! -f "$DIRECTORIO_CLAVES/altas/solicitud.json" ]]; then
  preparar_mi_acceso "$usuario" || return
 fi
 echo 'Se enviarán tu usuario, el nombre del equipo y tus dos claves públicas al receptor de Deepines.'
 confirmar_operacion '¿Enviar la solicitud de acceso?' || return 1
 alta_https_python send "$archivo"
}

recoger_mi_acceso () {
 local rc
 ((EUID!=0)) || { echo 'Ejecuta el asistente con tu usuario habitual, sin sudo.' >&2; return 1; }
 alta_https_python collect; rc=$?
 ((rc==0)) || return "$rc"
 importar_mi_acceso "$DIRECTORIO_CLAVES/altas/respuesta-recibida.json"
}


alta_automatica_python () {
 python3 - "$@" <<'DEEPINES_AUTO_V2'
"""Shared strict enrollment protocol. No private keys in the public receiver."""
import base64
import hashlib
import json
import re
import time
import uuid
from nacl.signing import SigningKey, VerifyKey
from nacl.public import PrivateKey, PublicKey, SealedBox

USERS = ('fenoll', 'car', 'isaias', 'xoas', 'eli')
ORIGIN = 'https://altas.deepines.com'
POLICY = {'endpoint': 'server2.marfen.es:51821',
 'server_wireguard_public_key': 'sHAViR1R3ICIaIyS3I5nyI9FZnOB8FOajCyckj9DCiw=',
 'server_ssh_public_key': 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINDuhYHl/rBCSBrVRAr86dq3fBdKl9H5cOq/V1mI3jBO',
 'repository_address': '192.0.2.107', 'allowed_ips': ['192.0.2.107/32'],
 'ssh_port': 22, 'repository_number': 6}

def require(ok, message='Datos no válidos.'):
    if not ok: raise ValueError(message)

def canonical(obj):
    return json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode()

def b64(data): return base64.urlsafe_b64encode(data).decode().rstrip('=')
def unb64(data):
    require(isinstance(data, str) and re.fullmatch(r'[A-Za-z0-9_-]+', data) is not None and len(data) < 20000)
    return base64.b64decode(data + '=' * (-len(data) % 4), altchars=b'-_', validate=True)

def digest(token):
    require(isinstance(token, str) and re.fullmatch(r'[A-Za-z0-9_-]{43}', token) is not None)
    return hashlib.sha256(token.encode()).hexdigest()

def signed(data, key): return b64(SigningKey(key).sign(canonical(data)))
def verified(token, public):
    raw = VerifyKey(public).verify(unb64(token))
    result = json.loads(raw)
    require(canonical(result) == raw)
    return result

def invitation(token, public, now=None):
    obj = verified(token, public)
    now = int(time.time()) if now is None else now
    require(set(obj) == {'kind','id','user','issued','expires'} and obj['kind'] == 'invite-v2')
    require(obj['user'] in USERS and str(uuid.UUID(obj['id'])) == obj['id'])
    require(type(obj['issued']) is int and type(obj['expires']) is int)
    require(obj['issued'] <= now + 60 and now < obj['expires'] <= obj['issued'] + 7 * 86400,
            'Invitación no válida o caducada.')
    return obj

def validate_request(obj):
    require(type(obj) is dict and set(obj) == {'id','device','ssh_public','box_public'})
    require(str(uuid.UUID(obj['id'])) == obj['id'])
    require(type(obj['device']) is str and re.fullmatch(r'[a-z0-9][a-z0-9-]{0,23}',obj['device']) is not None)
    parts = obj['ssh_public'].split()
    require(len(parts) == 2 and parts[0] == 'ssh-ed25519')
    raw = base64.b64decode(parts[1], validate=True)
    require(len(raw) == 51 and raw[:19] == b'\0\0\0\x0bssh-ed25519\0\0\0\x20')
    require(len(unb64(obj['box_public'])) == 32)
    return obj

def seal(obj, recipient, signing_key):
    ciphertext = SealedBox(PublicKey(unb64(recipient))).encrypt(canonical(obj))
    return signed({'kind':'sealed-v2','ciphertext':b64(ciphertext)}, signing_key)

def unseal(token, private, signing_public):
    obj = verified(token, signing_public)
    require(set(obj) == {'kind','ciphertext'} and obj['kind'] == 'sealed-v2')
    return json.loads(SealedBox(PrivateKey(private)).decrypt(unb64(obj['ciphertext'])))

"""Embedded client: local SSH key, browser pairing and authenticated sealed VPN delivery."""
import fcntl
import os
from pathlib import Path
import secrets
import stat
import subprocess
import sys
import time
import urllib.request
from urllib.parse import urlencode


# Replaced by the build with the verified broker response public key (not a secret).
RESPONSE_PUBLIC = '9ceddc8a397b63497067a1f9123e02099ba85a7ef93dcd8b04fec7fdec24fe91'

def save(path, data):
    raw=data.encode() if isinstance(data,str) else data
    if path.exists():
        require(not path.is_symlink() and path.read_bytes()==raw,'Ya existe una configuración diferente. Se conserva sin cambios.');return
    fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
    with os.fdopen(fd,'wb') as f:f.write(raw);f.flush();os.fsync(f.fileno())

def paths():
    require(os.geteuid()!=0,'Abre el asistente con tu usuario habitual, sin sudo.')
    home=Path.home();require(home.stat().st_uid==os.getuid() and not home.is_symlink())
    for rel in ('.config','.config/deepines','.config/deepines/ssh','.config/deepines/vpn','.config/deepines/altas'):
        p=home/rel;require(not p.is_symlink());p.mkdir(mode=0o700,exist_ok=True)
        require(p.is_dir() and p.stat().st_uid==os.getuid())
        if rel!='.config':p.chmod(0o700)
    return home/'.config/deepines'

def private_read(path):
    fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW)
    with os.fdopen(fd,'rb') as f:
        s=os.fstat(f.fileno());require(stat.S_ISREG(s.st_mode) and s.st_uid==os.getuid() and not s.st_mode&0o077 and s.st_size<32768)
        return f.read()

def api(path,data,token=''):
    headers={'Content-Type':'application/json'}
    if token:headers['Authorization']='Bearer '+token
    req=urllib.request.Request(ORIGIN+path,data=canonical(data),headers=headers,method='POST')
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self,*a,**kw):raise ValueError('Redirección no admitida.')
    with urllib.request.build_opener(NoRedirect).open(req,timeout=20) as r:
        require(r.status==200 and r.geturl()==ORIGIN+path);data=r.read(32769);require(len(data)<=32768);return json.loads(data)

def prepare(base,device):
    p=base/'altas/automatico.json'
    if p.exists():return json.loads(private_read(p))
    require(not (base/'altas/solicitud.json').exists() and not list((base/'vpn').glob('*.conf')),
            'Este equipo tiene un acceso anterior. Se conserva: consulta al administrador antes de sustituirlo.')
    require(re.fullmatch(r'[a-z0-9][a-z0-9-]{0,23}',device) is not None,'Usa letras minúsculas, números o guiones en el nombre del equipo.')
    key=base/'ssh/id_ed25519'
    if not key.exists():
        require(not key.is_symlink() and not key.with_suffix('.pub').exists())
        print('Elige una frase de paso para proteger tu clave SSH en este ordenador.',flush=True)
        with open('/dev/tty','rb',buffering=0) as tty:
            r=subprocess.run(['ssh-keygen','-q','-t','ed25519','-a','64','-f',str(key),'-C','deepines-'+device],stdin=tty)
        require(r.returncode==0,'No se pudo crear la clave SSH.')
    private_read(key)
    pub=' '.join(key.with_suffix('.pub').read_text().split()[:2])
    transport=base/'altas/transporte.key'
    if not transport.exists():save(transport,bytes(PrivateKey.generate()))
    transport_key=PrivateKey(private_read(transport))
    request={'id':str(uuid.uuid4()),'device':device,'ssh_public':pub,'box_public':b64(bytes(transport_key.public_key))}
    validate_request(request)
    state={'request':request,'browser':secrets.token_urlsafe(32),'receipt':secrets.token_urlsafe(32)}
    save(p,canonical(state));return state

def apply(base,state,response,work):
    obj=unseal(response,private_read(base/'altas/transporte.key'),bytes.fromhex(RESPONSE_PUBLIC))
    require(set(obj)=={'kind','request','username','vpn_address','wireguard_private','wireguard_public','policy'})
    require(obj['kind']=='access-v2' and obj['request']==state['request'] and obj['username'] in USERS and obj['policy']==POLICY)
    require(obj['vpn_address'] in ['10.79.113.'+str(n)+'/32' for n in range(16,26)])
    private=base64.b64decode(obj['wireguard_private'],validate=True);require(len(private)==32)
    require(base64.b64encode(bytes(PrivateKey(private).public_key)).decode()==obj['wireguard_public'])
    profile='RepoDeepines-'+obj['username']+'-'+state['request']['device']
    config='[Interface]\nPrivateKey = '+obj['wireguard_private']+'\nAddress = '+obj['vpn_address']+'\nMTU = 1380\n\n[Peer]\nPublicKey = '+POLICY['server_wireguard_public_key']+'\nEndpoint = '+POLICY['endpoint']+'\nAllowedIPs = 192.0.2.107/32\nPersistentKeepalive = 25\n'
    save(base/'vpn/client.key',obj['wireguard_private']+'\n');save(base/'vpn/client.pub',obj['wireguard_public']+'\n')
    save(base/'vpn'/f'{profile}.conf',config)
    save(base/'ssh/known_hosts','192.0.2.107 '+POLICY['server_ssh_public_key']+'\n')
    ssh='Host repo-deepines\n    HostName 192.0.2.107\n    User '+obj['username']+'\n    Port 22\n    IdentityFile "'+str(base/'ssh/id_ed25519')+'"\n    IdentitiesOnly yes\n    StrictHostKeyChecking yes\n    UserKnownHostsFile "'+str(base/'ssh/known_hosts')+'"\n    PasswordAuthentication no\n    KbdInteractiveAuthentication no\n    ForwardAgent no\n'
    save(base/'ssh/config',ssh)
    work=Path(work);require(work.is_absolute() and not work.is_symlink() and work.is_relative_to(Path.home()))
    work.mkdir(mode=0o700,parents=True,exist_ok=True)
    save(work/'.config','servidor-ssh=repo-deepines\nvpn-perfil='+profile+'\nnombre-usuario='+obj['username']+'\nvps_repo=Deepines_6 22 6\ngit_repo=\n')
    save(base/'altas/automatico-aprobado.json',canonical({'id':state['request']['id'],'username':obj['username'],'profile':profile,'address':obj['vpn_address']}))
    print('Acceso asignado. Preparando la conexión personal.',flush=True)

def main():
    os.umask(0o077);base=paths()
    with open(base/'altas/automatico.lock','a') as lock:
        fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
        device=sys.argv[1];work=sys.argv[2]
        state=prepare(base,device)
        api('/v2/pair',state)
        status=api('/v2/status',{'id':state['request']['id']},state['receipt'])
        if status['state']=='pairing':
            url=ORIGIN+'/registro#'+urlencode({'id':state['request']['id'],'token':state['browser']})
            print('Se abrirá la página de registro. Pega allí tu código de invitación y vuelve a esta ventana.',flush=True)
            if os.environ.get('DEEPINES_TEST_NO_BROWSER')!='1':
                r=subprocess.run(['xdg-open',url],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
                if r.returncode:print('No se pudo abrir el navegador. Abre esta dirección en este equipo: '+url,flush=True)
        for _ in range(180):
            status=api('/v2/status',{'id':state['request']['id']},state['receipt'])
            if status['state']=='ready':apply(base,state,status['response'],work);return
            require(status['state'] not in ('failed','revoked'),status.get('message') or 'Acceso no disponible.')
            time.sleep(5)
        print('El registro continúa pendiente. Vuelve a ejecutar el asistente: conservará tus claves y retomará el alta.')
        raise SystemExit(2)

if __name__=='__main__':
    try:main()
    except ValueError as e:print('No se completó el registro: '+str(e),file=sys.stderr);raise SystemExit(1)
    except Exception:print('No se completó el registro. Se conservan las claves. Revisa la conexión o consulta al administrador.',file=sys.stderr);raise SystemExit(1)

DEEPINES_AUTO_V2
}
# New automatic enrollment and daily workflow. Embedded in the independent assistant.
asegurar_acceso () {
 obtener_lista_repo || return
 configurar_opciones_ssh
 if ! nmcli -g connection.uuid connection show "$VPN_PERFIL" >/dev/null 2>&1; then
  if [[ -f "$DIRECTORIO_CLAVES/vpn/$VPN_PERFIL.conf" ]]; then
   configurar_vpn "$DIRECTORIO_CLAVES/vpn/$VPN_PERFIL.conf" "$VPN_PERFIL" || return
  else
   echo "Falta el perfil personal. Retoma el alta desde la opción Acceso." >&2; return 1
  fi
 fi
 if ! LC_ALL=C nmcli -t -f NAME connection show --active | grep -Fxq -- "$VPN_PERFIL"; then
  echo 'Conectando tu VPN de repositorios...'
  conectar_vpn "$VPN_PERFIL" || return
 fi
 if [[ -f "$DIRECTORIO_CLAVES/altas/automatico-aprobado.json" || -f "$DIRECTORIO_CLAVES/altas/respuesta.json" ]]; then
  if [[ -z ${SSH_AUTH_SOCK:-} ]]; then
   eval "$(ssh-agent -s)" >/dev/null || return
   DEEPINES_AGENT_PROPIO=1
   trap '[[ ${DEEPINES_AGENT_PROPIO:-0} == 1 ]] && ssh-agent -k >/dev/null 2>&1' EXIT
  fi
  if ! ssh-add -T "$DIRECTORIO_CLAVES/ssh/id_ed25519.pub" >/dev/null 2>&1; then
   echo 'Desbloquea tu clave SSH local. No se envía esta frase al servidor.'
   ssh-add -t 8h "$DIRECTORIO_CLAVES/ssh/id_ed25519" || return
  fi
 fi
 PUERTO_SELECCIONADO=${PUERTO_SELECCIONADO:-22}
 comprobar_acceso_ssh
}

alta_automatica () {
 local equipo nombre
 ((EUID!=0)) || { echo 'Abre el asistente con tu usuario habitual, sin sudo.' >&2; return 1; }
 instalar_dependencias_vpn || return
 if ! python3 -c 'import nacl' >/dev/null 2>&1; then
  echo 'Se instalará python3-nacl desde APT para recibir de forma cifrada tu perfil personal.'
  confirmar_operacion '¿Instalar la herramienta necesaria?' || return 1
  vpn_admin apt-get update && vpn_admin apt-get install -y python3-nacl || return
 fi
 if [[ -f "$DIRECTORIO_CLAVES/altas/automatico.json" ]]; then
  equipo=reanudar
 else
  read -r -p 'Nombre de este equipo (ejemplo: portatil-fenoll): ' equipo || return 1
 fi
 alta_automatica_python "$equipo" "$CARPETA_TRABAJO" || return
 obtener_lista_repo || return
 nombre=$VPN_PERFIL
 if ! nmcli -g connection.uuid connection show "$nombre" >/dev/null 2>&1; then
  configurar_vpn "$DIRECTORIO_CLAVES/vpn/$nombre.conf" "$nombre" || return
 fi
 PUERTO_SELECCIONADO=22
 asegurar_acceso || return
 echo 'Alta completada: VPN y acceso al repositorio comprobados.'
}

publicar_y_sincronizar () {
 asegurar_acceso || return
 local fase=publicar
 [[ ! -f "$CARPETA_TRABAJO/.publicacion-pendiente" ]] || fase=$(<"$CARPETA_TRABAJO/.publicacion-pendiente")
 if [[ "$fase" != sincronizar ]]; then
  confirmar_operacion '¿Publicar ahora los paquetes pendientes del repositorio?' || return 2
  ejecutar_comando_en_servidor sudo -n -- /usr/local/bin/actualiza || return
  printf 'sincronizar\n' > "$CARPETA_TRABAJO/.publicacion-pendiente"
 fi
 confirmar_operacion '¿Sincronizar ahora la publicación con el espejo configurado?' || return 2
 ejecutar_comando_en_servidor sudo -n -- /usr/local/bin/deepines-sincroniza "$REPO_NUMERO_SELECIONADO" || return
 rm -f -- "$CARPETA_TRABAJO/.publicacion-pendiente"
 echo 'Publicación y sincronización completadas.'
}

cerrar_sesion_mantenedor () {
 if [[ -f "$CARPETA_TRABAJO/.publicacion-pendiente" ]]; then
  if [[ $(<"$CARPETA_TRABAJO/.publicacion-pendiente") == sincronizar ]]; then
   echo 'ATENCIÓN: hay una publicación pendiente de sincronizar con el espejo.'
  else
   echo 'ATENCIÓN: has subido paquetes que todavía debes publicar y sincronizar.'
  fi
  if confirmar_operacion '¿Completar ahora la publicación pendiente?'; then
   publicar_y_sincronizar || { echo 'No se ha completado. El aviso se conserva; la VPN sigue disponible.'; return 1; }
  else
   echo 'Los paquetes se conservan. El aviso volverá a aparecer al abrir el asistente.'
  fi
 fi
 if LC_ALL=C nmcli -t -f NAME connection show --active | grep -Fxq -- "$VPN_PERFIL"; then
  if confirmar_operacion '¿Desconectar la VPN de Deepines y cerrar el asistente?'; then
   vpn_admin nmcli connection down "$VPN_PERFIL" || { echo 'No se pudo desconectar. Se mantiene abierto el asistente.'; return 1; }
  else
   echo 'La VPN se mantiene conectada. Vuelves al menú.'
   return 1
  fi
 fi
 SEGUIR_MP=false
 echo 'Sesión terminada.'
}

fin_de_subida () {
 local respuesta
 read -r -p '¿Vas a subir más paquetes? (S/n): ' respuesta || return 1
 case "$respuesta" in
  n|N) cerrar_sesion_mantenedor ;;
  *) echo 'La VPN sigue conectada. Añade los siguientes paquetes y vuelve a elegir Subir.' ;;
 esac
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
 case "${1:-}" in
  --alta-automatica) alta_automatica; exit $? ;;
  --preparar-acceso) preparar_mi_acceso "${2:-}" "${3:-}"; exit $? ;;
  --solicitar-acceso) solicitar_mi_acceso "${2:-}"; exit $? ;;
  --recoger-acceso) recoger_mi_acceso; exit $? ;;
  --importar-acceso) importar_mi_acceso "${2:-}"; exit $? ;;
  --comprobar-acceso) comprobar_mi_acceso; exit $? ;;
  --alta) menu_alta; exit $? ;;
  --configurar-vpn) configurar_vpn "${2:-}" "${3:-RepoDeepines}"; exit $? ;;
  --carpeta-claves) preparar_directorio_claves && printf '%s\n' "$DIRECTORIO_CLAVES"; exit $? ;;
  --conectar-vpn) conectar_vpn "${2:-RepoDeepines}"; exit $? ;;
 esac
 if [[ ! -f "$ARCHIVO_CONFIG" ]] || ! { obtener_lista_repo && comprobar_lista_repo && comprobar_nombre_usuario; } 2>/dev/null; then
  alta_automatica || exit $?
  obtener_lista_repo
 fi

 comprobar_comandos_requeridos || exit 1
 secuencia_inicio || exit 1
 menu_principal
fi
