"Después del juego es antes del juego"
Sepp Herberger

miércoles, 19 de junio de 2019

Captura remota de pantalla en las sesiones de los alumnos.

Una profesora me pidió este curso escribir un programa para realizar capturas de pantalla de los escritorios de los alumnos de forma periódica. El objetivo era tener en su carpeta personal una colección con las capturas de pantalla realizadas cada X minutos de los PC de todos los alumnos del aula, para controlar a posteriori el trabajo que habían estado haciendo durante la clase.

Capturar la pantalla desde un script no es complicado, aunque si quieres hacerlo de forma remota y traerlo al ordenador del profesor hay que afinar un poco la cosa.

Como paso imprescindible e inicial es obligatorio que entre el ordenador del profesor y los de los alumnos exista una relación de confianza ssh que nos permita conectar desde la cuenta de root del PC del profesor sin necesidad de introducir contraseña. Tras generar y copiar las claves, en /root/.ssh/id_rsa y /root/.ssh/id_rsa.pub del profesor tendremos las claves pública y privada, mientras que en /root/.ssh/... de los alumnos tendremos la clave privada.

Una vez resuelto el problema de la comunicación entre profesor y alumnos, tenemos que pensar en los posibles escenarios que nos encontramos en nuestras aulas:

  • Aula aislada: profesor y alumnos están una red privada con rango de direcciones propio, mediante una VLAN creada en el switch. En mi caso con los alumnos tienen direccionamamiento 192.168.0.200-253, sin importar si se usan portátiles o equipos de sobremesa.
  • Aula integrada: profesor y alumnos no están aislados, si no que usan direcciones públicas de la red del centro. Hay que encontrar una manera de que el PC del profesor sepa a priori cuales son las IP de los PC de alumnos que dependen de él. Esta manera consiste en definir en el fichero /etc/escuela2.0 (el que usamos para configuraciones de nuestras máquinas) una variable llamada IES_IP_ALUMNOS, en la cual indicamos en rango de IP donde están los PC de los alumnos, por ejemplo: 172.23.241.70-85. El valor tomado por defecto es 192.168.0.200-253
  • Thinclients: en este caso también es un aula aislada, con direcciones del rango 192.168.0.200 en adelante, pero con una particularidad consistente en que con los thinclients las sesiones de los alumnos se ejecutan en el PC del profesor, en una sesión privada en la que la captura de pantalla se hace de una forma diferente. Para saber si el aula tiene thinclients usaremos la variable IES_ISLTSP de /etc/escuela2.0

Aparte de eso, la aplicación debe tener un interface gráfico sencillo para el usuario. Por esta razón he optado por hacer la aplicación de captura usando python e interface gráfico Qt con PyQt5. Para ello uso Qt Designer para diseñar el interface gráfico y Geany para escribir el programa.

Una vez implementado, los ficheros necesarios son distribuidos por la siguiente tarea puppet:
# cat xubuntu18_photomaton/manifests/init.pp
import "/etc/puppet/defines/*.pp"
class xubuntu18_photomaton {

  file {"/usr/local/bin/photomaton.py":
        owner=>root, group=>root, mode=>755,
        source=>"puppet:///modules/xubuntu18_photomaton/photomaton.py",
  }


  file {"/usr/local/bin/photomaton_ui.py":
        owner=>root, group=>root, mode=>755,
        source=>"puppet:///modules/xubuntu18_photomaton/photomaton_ui.py",
  }

  file {"/usr/local/bin/photomaton.jpg":        #/usr/share/icons/photomaton.jpg
        owner=>root, group=>root, mode=>644,
        source=>"puppet:///modules/xubuntu18_photomaton/photomaton.jpg",
  }


  file {"/usr/share/applications/Photomaton.desktop" :
          owner => root , group =>root, mode => 644 ,
          source => "puppet:///modules/xubuntu18_photomaton/Photomaton.desktop",
          notify => Exec['update-desktop'],
  }
 
  exec { "update-desktop":
          path => "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
          command => "/usr/bin/update-desktop-database",
          refreshonly => true,
          require => [File["/usr/local/bin/photomaton.jpg"],File["/usr/share/applications/Photomaton.desktop"]],
  }

  line { sudoers:
            file => "/etc/sudoers",
            line => "%teachers ALL = (ALL) NOPASSWD: /usr/local/bin/photomaton.py",
            ensure => present
  }

  package { 'python3-paramiko': ensure => 'installed' }
  package { 'python3-nmap': ensure => 'installed' }
  package { 'python3-configobj':   ensure => 'installed' }  
  package { 'python3-psutil': ensure => 'installed' }
  package { 'python3-pyqt5': ensure => 'installed' }
}
Como vemos la tarea instala varios paquetes necesarios, copia los ficheros y añade una linea a sudoers para permitir a los miembros del grupo teachers ejecutar el programa con permisos de root (necesario para acceder a los pc de los alumnos).

Vamos primero el código del programa principal:
# cat xubuntu18_photomaton/files/photomaton.py
#!/usr/bin/python3
from photomaton_ui import *
import sys
import socket
import subprocess
import os, errno
from datetime import datetime
import paramiko
Para acabar un pequeño detalle: en las au
import time
from configobj import ConfigObj
import psutil
import nmap

class MainWindow(QtWidgets.QMainWindow, Ui_Photomaton):
   def __init__(self, *args, **kwargs):
       QtWidgets.QMainWindow.__init__(self, *args, **kwargs)
       self.setupUi(self)
       #Conectamos los botones con los eventos
       self.iniciarCaptura.clicked.connect(self.capturar)
       self.visualizarCapturas.clicked.connect(self.visualizar)
       self.finalizar.clicked.connect(self.finalizarApp)
       #Definimos el entorno, creando el directorio destino y leyendo escuela2.0
       hostname = socket.gethostname()
       #Como se llama usando sudo, el usuario original está en SUDO_USER

       try:
           self.user=os.environ["SUDO_USER"]       
       except Exception as e:
           print("No eres usuario sudo. Abortando")
           #self.user="root"
           sys.exit(0)  

       home = os.path.expanduser("~"+self.user) #Directorio $HOME del usuario
       self.pathcapturas=home+"/capturas/"+hostname
       config = ConfigObj("/etc/escuela2.0")
       self.uso=config.get('USO') #infolab/siatic/...
       self.isltsp=config.get('IES_ISLTSP') #true si tiene thinclients
       self.ip_alumnos=config.get('IES_IP_ALUMNOS') # Rango de ip de los alumnos, para aulas de workstations.    
       self.capturando=False         
       if self.ip_alumnos is None or self.ip_alumnos == "" :
          self.ip_alumnos="192.168.0.200-253" # Rango por defecto en aulas con vlan
       try:
          comando="su "+self.user+" -c 'mkdir -p "+self.pathcapturas+"'"
          os.system(comando)
       except FileExistsError:
          # directory already exists
          pass     
         
   def finalizarApp(self):
       if self.capturando == False:
          sys.exit(0)
       else:
          self.capturando=False
    
   
   def visualizar(self):                            
       comando="su "+self.user+" -c 'nohup thunar "+self.pathcapturas+" &'" 
       os.system(comando)   
   
   def capturar_ip(self, ip):          
       
       s = socket.socket()       #Quizá esto se podria quiar, el ssh_client.connnect ya tiene timeout
       s.settimeout(30)
       address = ip
       port = 22         
       try:
           #Intenta conectar por ssh a ver si la IP acepta peticiones ssh
           s.connect((address, port))  #Si falla se va a la excepcion
           s.close()    
           #Conectamos por ssh y ejecutamos el comando de captura.
           ssh_client = paramiko.SSHClient()
           ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
           ssh_client.connect(ip, 22, 'root', key_filename="/root/.ssh/id_rsa" , timeout=30) 
           fecha=datetime.now().strftime("%Y-%m-%d-%H:%M:%S")
           sleeptime = 0.001
           comando='rm -f /tmp/captura.jpg; pc=$(hostname); user=$(who | grep "(:0)" | head -1 | cut -f1 -d" "); test -n "$user" && (su $user -c "DISPLAY=:0 import -window root /tmp/captura.jpg"; chmod 777 /tmp/captura.jpg; echo -n $pc/$user)'
           #print("Ejecutando "+comando)
           ssh_transp = ssh_client.get_transport()
           chan = ssh_transp.open_session()
           
           chan.setblocking(0)
           outdata=""
           
           chan.exec_command(comando)
           while True:  # Hay que esperar a que acabe el comando
               while chan.recv_ready():
                  outdata += chan.recv(1000).decode('ascii')
               if chan.exit_status_ready():  # If completed
                  break
               time.sleep(sleeptime)
           
           #Como salida se devuelve "pc/usuario"
           signatura=outdata           
           #Aprovechando la conexion ssh se usa sftp para recuperar el fichero /tmp/captura.jpg y guardarlo en la ruta de destino
           sftp = ssh_client.open_sftp()
           destino=self.pathcapturas+"/"+signatura         
           try:
             comando='su '+self.user+' -c "mkdir -p '+destino+'"'
             os.system(comando)              
           except FileExistsError:         
             pass  
           fichero=destino+"/"+fecha+".jpg"           
           sftp.get("/tmp/captura.jpg", "/tmp/captura-local.jpg") #Traemos el fichero a local
           comando='su '+self.user+' -c "cp /tmp/captura-local.jpg '+fichero+'"'
           #print("Ejecutando "+comando)
           os.system(comando)           
           
           # Cerramos todo lo abierto
           ssh_transp.close()
           sftp.close()
           ssh_client.close()           
       except Exception as e:
           print("Error "+str(e))
   
   def capturar_thinclients(self):
   
       #Obtenemos la lista de usuarios conectados en el servidor de aula, filtramos por aquellos que están conectados con la ip 192.X.Y.Z (thinclients)
       for usuario in psutil.users():
           if usuario.host[:4] == "192." :
               try:
                   #Sacamos el home del usuario con getent
                   comando="getent passwd "+usuario.name
                   process = subprocess.Popen(comando.split(), stdout=subprocess.PIPE) 
                   output, error = process.communicate()
                   home_user=output.decode("ascii").split(":")[5]
                   
                   #Conectamos con el thinclient para sacar el hostname
                   ssh_client = paramiko.SSHClient()
                   ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
                   ssh_client.connect(usuario.host, 22, 'root', key_filename="/root/.ssh/id_rsa", timeout=30)               
                   fecha=datetime.now().strftime("%Y-%m-%d-%H:%M:%S")
                   sleeptime = 0.001
                   comando='cat /etc/hostname | tr -d "\n"'
                   ssh_transp = ssh_client.get_transport()
                   chan = ssh_transp.open_session()
                   # chan.settimeout(3 * 60 * 60)
                   chan.setblocking(0)
                   outdata=""
                   chan.exec_command(comando)
                   while True:  # Hay que esperar a que acabe el comando
                      while chan.recv_ready():
                         outdata += chan.recv(1000).decode('ascii')
                      if chan.exit_status_ready():  # If completed
                         break
                      time.sleep(sleeptime)
                   host=outdata    
                   # Cerramos todo lo abierto
                   ssh_transp.close()
                   ssh_client.close()                  
                   
                   #Ejecutamos el comando de captura de la sesión del usuario. Se usa os.system porque para comandos complejos es mejor que subprocess.popen
                   comando='su '+usuario.name+' -c  "DISPLAY='+usuario.host+':7  XAUTHORITY='+home_user+'/.Xauthority import -window root /tmp/capture.jpg"'
                   os.system(comando)
                   
                   #Copiamos la captura a la carpeta destino  
                   destino=self.pathcapturas+"/"+host+"/"+usuario.name
                   try:
                      comando='su '+self.user+' -c "mkdir -p '+destino+'"'
                      os.system(comando)
                   except FileExistsError:         
                      pass  
                   fichero=destino+"/"+fecha+".jpg"      
                   comando='su '+self.user+' -c "cp /tmp/capture.jpg '+fichero+'"'
                   os.system(comando)
                   comando='rm -f /tmp/capture.jpg'
                   os.system(comando)
           
               except Exception as e:       
                   pass
                   
   def capturar(self):       
    
       try:
            segundos=int(self.intervaloCaptura.text())*60
       except ValueError:
            segundos=0
        
       self.capturando=True     
       self.iniciarCaptura.setText("Capturando...")
       self.finalizar.setText("Parar captura")
       self.iniciarCaptura.setEnabled(False)
       while self.capturando == True: 
            self.iniciarCaptura.setText("Capturando...")
            self.iniciarCaptura.setEnabled(False)
            self.do_captura()
            if segundos == 0:            
               break
            else: #Espera activa hasta la próxima captura
               for i in range(segundos):
                  time.sleep(1)
                  app.processEvents()
                  if self.capturando==False: break                
       self.iniciarCaptura.setEnabled(True)
       self.iniciarCaptura.setText("Iniciar Captura")
       self.finalizar.setText("Finalizar")
       self.capturando=False
       

   def do_captura(self):
       app.processEvents()
       #print("Inicio captura")
       if self.isltsp == "true":
           self.capturar_thinclients()
       else:          
           nm = nmap.PortScanner()
           nm.scan(self.ip_alumnos, '22')
           for ip in  nm.all_hosts():# Equipos conectados, up.     
               #print("Procesando "+ip)
               if nm[ip]['tcp'][22]['state']=="open": 
                  #print("Puerto ssh abierto") 
                  self.capturar_ip(ip)               
               app.processEvents()   # Para procesar clicks de raton durante el bucle.                     
       app.processEvents()
       #print("Fin captura")
    

if __name__ == "__main__":
   app = QtWidgets.QApplication([])
   window = MainWindow()
   window.show()
   app.exec_()
Como se puede apreciar es un código python que utiliza clases de conexión ssh para ejecutar en remoto los comandos bash de captura de sesión y traer los ficheros con la captura al home del usuario en el PC del profesor.

El fichero photomaton.jpg contendría la imagen:


Seguimos con el fichero .desktop, el acceso directo para poner en el menú principal y en el escritorio. Nótese el uso de "gksu" para ejecutar como root el programa photomaton.py.
# cat xubuntu18_photomaton/files/Photomaton.desktop                                                   

[Desktop Entry]
Version=1.0
Encoding=UTF-8
Name=Photomaton
Type=Application
Exec=gksu /usr/local/bin/photomaton.py
TryExec=
Icon=/usr/local/bin/photomaton.jpg
X-GNOME-DocPath=
Terminal=false
Name[es_ES]=Photomaton
GenericName[es_ES]=
Comment[es_ES]=
GenericName=
Comment=
Continuamos con el fichero .ui en formato XML con el interface de usuario creado por Qt Designer:
# cat xubuntu18_photomaton/files/photomaton.ui                                                         
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>Photomaton</class>
 <widget class="QDialog" name="Photomaton">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>508</width>
    <height>212</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Photomaton</string>
  </property>
  <widget class="QPushButton" name="iniciarCaptura">
   <property name="geometry">
    <rect>
     <x>290</x>
     <y>70</y>
     <width>191</width>
     <height>28</height>
    </rect>
   </property>
   <property name="text">
    <string>Iniciar Captura</string>
   </property>
  </widget>
  <widget class="QPushButton" name="visualizarCapturas">
   <property name="geometry">
    <rect>
     <x>290</x>
     <y>110</y>
     <width>191</width>
     <height>28</height>
    </rect>
   </property>
   <property name="text">
    <string>Visualizar Capturas</string>
   </property>
  </widget>
  <widget class="QPushButton" name="finalizar">
   <property name="geometry">
    <rect>
     <x>290</x>
     <y>150</y>
     <width>191</width>
     <height>28</height>
    </rect>
   </property>
   <property name="text">
    <string>Finalizar</string>
   </property>
  </widget>
  <widget class="QLabel" name="label">
   <property name="geometry">
    <rect>
     <x>290</x>
     <y>30</y>
     <width>151</width>
     <height>16</height>
    </rect>
   </property>
   <property name="text">
    <string>Intervalo capturas (min):</string>
   </property>
  </widget>
  <widget class="QLineEdit" name="intervaloCaptura">
   <property name="geometry">
    <rect>
     <x>440</x>
     <y>20</y>
     <width>41</width>
     <height>28</height>
    </rect>
   </property>
   <property name="inputMask">
    <string>09</string>
   </property>
   <property name="text">
    <string>00</string>
   </property>
   <property name="maxLength">
    <number>2</number>
   </property>
  </widget>
  <widget class="QLabel" name="label_2">
   <property name="geometry">
    <rect>
     <x>10</x>
     <y>20</y>
     <width>261</width>
     <height>181</height>
    </rect>
   </property>
   <property name="text">
    <string/>
   </property>
   <property name="pixmap">
    <pixmap>/usr/local/bin/photomaton.jpg</pixmap>
   </property>
  </widget>
 </widget>
 <resources/>
 <connections/>
</ui>
Y seguimos con el fichero photomaton_ui.py, generado a partir del fichero photomaton.ui anterior:
# cat xubuntu18_photomaton/files/photomaton_ui.py 
# -*- coding: utf-8 -*-

# Form implementation generated from reading ui file 'photomaton.ui'
#
# Created by: PyQt5 UI code generator 5.10.1
#
# WARNING! All changes made in this file will be lost!

from PyQt5 import QtCore, QtGui, QtWidgets

class Ui_Photomaton(object):
    def setupUi(self, Photomaton):
        Photomaton.setObjectName("Photomaton")
        Photomaton.resize(508, 212)
        self.iniciarCaptura = QtWidgets.QPushButton(Photomaton)
        self.iniciarCaptura.setGeometry(QtCore.QRect(290, 70, 191, 28))
        self.iniciarCaptura.setObjectName("iniciarCaptura")
        self.visualizarCapturas = QtWidgets.QPushButton(Photomaton)
        self.visualizarCapturas.setGeometry(QtCore.QRect(290, 110, 191, 28))
        self.visualizarCapturas.setObjectName("visualizarCapturas")
        self.finalizar = QtWidgets.QPushButton(Photomaton)
        self.finalizar.setGeometry(QtCore.QRect(290, 150, 191, 28))
        self.finalizar.setObjectName("finalizar")
        self.label = QtWidgets.QLabel(Photomaton)
        self.label.setGeometry(QtCore.QRect(290, 30, 151, 16))
        self.label.setObjectName("label")
        self.intervaloCaptura = QtWidgets.QLineEdit(Photomaton)
        self.intervaloCaptura.setGeometry(QtCore.QRect(440, 20, 41, 28))
        self.intervaloCaptura.setMaxLength(2)
        self.intervaloCaptura.setObjectName("intervaloCaptura")
        self.label_2 = QtWidgets.QLabel(Photomaton)
        self.label_2.setGeometry(QtCore.QRect(10, 20, 261, 181))
        self.label_2.setText("")
        self.label_2.setPixmap(QtGui.QPixmap("/usr/local/bin/photomaton.jpg"))
        self.label_2.setObjectName("label_2")

        self.retranslateUi(Photomaton)
        QtCore.QMetaObject.connectSlotsByName(Photomaton)

    def retranslateUi(self, Photomaton):
        _translate = QtCore.QCoreApplication.translate
        Photomaton.setWindowTitle(_translate("Photomaton", "Photomaton"))
        self.iniciarCaptura.setText(_translate("Photomaton", "Iniciar Captura"))
        self.visualizarCapturas.setText(_translate("Photomaton", "Visualizar Capturas"))
        self.finalizar.setText(_translate("Photomaton", "Finalizar"))
        self.label.setText(_translate("Photomaton", "Intervalo capturas (min):"))
        self.intervaloCaptura.setInputMask(_translate("Photomaton", "09"))
        self.intervaloCaptura.setText(_translate("Photomaton", "00"))


if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    Photomaton = QtWidgets.QDialog()
    ui = Ui_Photomaton()
    ui.setupUi(Photomaton)
    Photomaton.show()
    sys.exit(app.exec_())
Un vez distribuidos los ficheros con la tarea puppet el modo de funcionamiento es lanzarlo en el ordenador del profesor y ejecutarlo para conectar a los equipos de alumnos detectados como encendidos y realizar las capturas. La pantalla es:


En "Intervalo de capturas (min)" indicamos cada cuanto tiempo queremos hacer las capturas de pantalla. Si dejamos 0 solo se realiza una captura en el momento en que damos al botón "Iniciar Captura".


Mientras que está realizando las capturas el botón "Iniciar Captura" se desactiva. El proceso de captura para pulsando el botón "Parar Captura". A veces tarda un rato en responder ya que no se puede interrumpir mientras está ejecutando la conexión con uno de los clientes.

El botón "Visualizar Capturas" nos lleva al directorio donde se realizan las capturas para que veamos las imágenes. Las capturas se estructuran en directorios separados según el patrón: ~/Capturas/$pc_profesor/$pc_alumno/$login_alumno/*.jpg


Los nombres de ficheros jpg nos indican la fecha y hora de captura:

Y ya está. Con este sistema el profesor puede realizar un seguimiento desatendido completo de la actividad de los alumnos. No solo eso: nosotros también desde nuestro PC podremos realizar seguimiento de ordenadores de uso público del centro.


Ahora que con la magnífica serie Chernobyl (quizá desvirtuada con algunos elementos de ficción y drama innecesarios, en detrimento de otros más interesantes como la construcción del sarcófago y los biorobots) se ha reavivado el tema, dejo varios enlaces sobre accidentes nucleares bastante silenciados:

  • Three Mile Island: o como "refrigerar" un reactor con vapor de agua en lugar de con agua líquida durante horas hasta fundir parcialmente el núcleo y no darse cuenta.
  • Vandellós I: o cómo quedarte a dos grados de explotar, mientras que el director de la central se iba a por tabaco en helicóptero a Barcelona y los trabajadores conseguían controlar la temperatura in extremis con el único turbosoplante que no se había parado por inundación.
  • Lucens: o cómo los suizos se lo intentan montar por su cuenta y funden un reactor el día de su puesta en funcionamiento. La única idea buena: como por si las moscas lo hicieron debajo de una montaña, solo se contaminó ésta.

Por supuesto, recomiendo todos los artículos de Yuri sobre Chernobyl:


Y no podía faltar:



miércoles, 12 de junio de 2019

Conexión automática a red Wifi

Ahora que llegan las vacaciones viene bien dar un repaso a todos los portátiles de los alumnos: ejecutar puppet, pkgsync, limpiar los usuarios cacheados,... No solo eso, también durante el curso de vez en cuando nos toca actuar sobre todos los portátiles de un armario para aplicar alguna configuración que nos han pedido o que soluciona un bug pendiente.

Hasta ahora para tener conectividad tras encender los portátiles había 2 alternativas:
  1. Conectarlos por red cableada a las tomas del suelo o un switch del aula.
  2. Iniciar sesión en cada uno de ellos y conectarlos manualmente a la red Wifi del aula.
Evidentemente ambas opciones son bastante reiterativas y aburridas. Lo ideal sería que para estos casos los portátiles se conectasen de forma autónoma a una red wifi y nosotros pudiesemos manejarlos de forma masiva con tmux-cssh.

Y esa es la idea de este post: basándome en un script de conexión que usa en su centro nuestro compañero Esteban lo he adaptado para que se conecte de forma autónoma a una red wifi concreta predefinida tan pronto como la detecte.

El código del script es (nota: he corregido algunas erratas en el script el 8/10/2019):
# cat /usr/local/bin/conecta-default-wifi
#!/bin/bash

idioma=$LC_ALL
export LC_ALL=C

wifi_maestra="WIFI_AULAS"
wifi_key="a12345678"
nombre_conexion="ies"

activado=$(nmcli radio wifi)
if [ "$activado" != "enabled" ]
then
    nmcli radio wifi on #activamos la wifi si no lo esta
fi
conectado=$(nmcli dev status | grep wifi |  grep -w -e 'connected' -e "conectado" | awk ' {print $4}')
if  [ "$conectado" != "$nombre_conexion" ]  # Si no estamos conectados a la wifi maestra...
then
      definida=$(nmcli con | grep wifi | grep -w "$nombre_conexion") #Si ya se definió la conexión en network manager la quitamos, para crearla de nuevo cuando nos conectemos
      if [ -n "$definida" ]
      then
          nmcli con delete "$nombre_conexion"conecta-default-wifi
      fi

      detectada=$(nmcli -f SSID,BSSID,SIGNAL,SECURITY dev wifi list | grep -w "$wifi_maestra")
      if [ -n "$detectada" ]  # Si la wifi maestra está al alcance....
      then
            #Si la wifi maestra está al alcance y no estamos en ella, conectamos automaticamente.
            nmcli dev wifi con "$wifi_maestra" password "$wifi_key" name "$nombre_conexion"
            #Si ya existiese la conession podriamos hacer:  nmcli con up $ssidconfigurado
      fi
fi

export LC_ALL=$idioma

exit 0
Que distribuyo con estas reglas puppet:
file {"/usr/local/bin/conecta-default-wifi":
        owner=>root, group=>root, mode=>755,
        source=>"puppet:///modules/xubuntu18_portatil_ajustes/conecta-default-wifi",
}

cron { "cron_conecta_default_wifi":
           command => "/usr/local/bin/conecta-default-wifi",
           user => root,
           minute => "*/3",
           ensure => present
}
Por tanto tenemos un script y una tarea cron que lo llama cada 3 minutos. El script hace lo siguiente:

  1. Configuramos parámetros para una red wifi con SSID "WIFI_AULAS", una clave fija y un nombre de conexión "ies" para el Network Manager.
  2. Si la tarjeta wifi está apagada en Network Manager, se enciende.
  3. Si la wifi está conectada a un punto wifi, mira si el nombre de la SSID es "WIFI_AULAS". En caso afirmativo, finalizamos el script y no continuamos en los pasos siguientes.
  4. Si existe alguna conexión en Network Manager con el nombre "ies", la borramos. Esto es simplemente para hacer limpieza si ya nos habíamos conectado antes.
  5. Escaneamos las redes wifi circundantes en busca de "WIFI_AULAS". Si la encontramos a nuestro alcance nos conectamos a ella creando una conexión "ies" en Network Manager.
  6. Todo esto se repite cada 3 minutos, de tal forma que tan pronto aparezca en el espectro radioeléctrico la wifi "WIFI_AULAS" el portátil se conectará a ella, desconectando de cualquier otra red wifi en la que estuviera enlazado y permaneciendo conectado mientras esté a su alcance.

Con esto el trabajo queda reducido a llegar al aula con un punto wifi (vale cualquier punto de acceso portátil, incluido un router casero de ADSL) configurado con WIFI_AULAS y la clave indicada, enchufarlo a un punto de red cableado, sacar los portátiles del armario y encenderlos.

En pocos minutos todos estarán conectados a la wifi sin ninguna actuación por nuestra parte y con tmux-cssh o ssh podremos controlarlos. Incluso si tenemos puppet y pgksync en modo automático se ejecutarán sin que tengamos que hacer nada. El punto de acceso solo lo usaremos cuando queramos trabajar de esta manera. El resto del tiempo estará apagado y guardado.


Debido a que te encuentras que la gente mas insospechada se niega a creer la gesta del Apolo XI, ahí va el documental definitivo:



jueves, 6 de junio de 2019

CloudReady: convirtiendo los portátiles en ChromeBooks

Tras la llegada de los portátiles Techcomputer TC11 he liberado un par de docenas de antiguos miniportátiles MSI L1350D y APD P10ALD. Son aparatos bastante antiguos y justitos (ambos con Intel Atom CPU N455 y 2GB de RAM) que ya daban problemas de usabilidad con Ubuntu 18.

Como he hecho en otros equipos obsoletos con buen resultado, probé a instalar FreeBSD/TrueOS pero la verdad es que no he notado mejora significativa. Pienso que la causa pudiera ser el limitado soporte a la tarjeta gráfica Intel que traen.

Luego probé con varias versiones de Android-x86 con idéntico resultado. Estaba a punto de probar Manjaro pero recordé que tenía pendiente testear una distribución que convierten un portátil en un ChromeBook, que es un sistema operativo ligero cuyo escritorio es un navegador Chrome y por tanto solo permite ejecutar dicho software y los plugins que instalemos en él. En algunos centros educativos están empezando a llegar este tipo de equipos. En otros países se usa masivamente en entornos educativos.

De esta manera tenemos un ordenador en el que hacemos login con las credenciales de Google y que solo permite trabajar con el navegador, en la nube (con Google Docs, Google Drive y las extensiones que instalemos de Chrome Web Store).

Por supuesto, el sistema Chrome OS es propietario de Google y no es fácil instalarlo en un equipo que no sea un ChromeBook certificado. Afortunadamente han salido clones y alternativas que remedan bastante bien el espíritu de Chrome OS. Aunque hay varios donde elegir me he decantado para probar por CloudReady en su versión Home (traducido: la gratuita). Tiene también buena pinta este desarrollo chino, ahora que Trump está en guerra comercial con China.

1. Descarga.

Lo descargamos de la página de Neverware. En caso de duda se descarga la imagen en un .zip para OSX. Lo descomprimimos y dentro hay un fichero .bin con la imagen de disco de arranque. La grabamos a un pendrive USB con:
sudo dd if=cloudready.bin of=/dev/sdX bs=4M
Luego reiniciamos el pc y arrancamos con el pendrive. La instalación comenzará.

2. Instalación.

Durante la instalación se borrará el disco completo, no es sencilla una instalación dual. Lo cierto es que CloudReady construye un sistema infernal basado en mas de 20 particiones, la causa al parecer es algo que viene de serie en Chromium OS. Esta estructura no es algo normal en Linux, pero si lo he visto muchas veces en Android.

La primera pantalla de instalación, con la bienvenida y la elección de idioma:


Luego nos pide una cuenta de gmail.com. Esta cuenta será la propietaria ("owner") del sistema instalado y nunca podrá borrarse. Por ello lo mejor es usar en la instalación una cuenta gmail.com genérica que nos permitirá entrar luego el pc. Una vez instalado todo el sistema, cualquier persona que tenga una cuenta de Gmail podrá iniciar sesión libremente sin problema (o usar una cuenta temporal de invitado, si no tiene/quiere usar su cuenta de gmail).


Evidentemente, para que esta cuenta pueda ser validada es necesario que el ordenador esté conectado a Internet, ya sea por cable o wifi. Esto es un imperativo siempre presente un Chromebook: un sistema diseñado para trabajar en la nube necesita Internet constante.

Por último nos pide el idioma de instalacion:


3. Configuración y uso.

Una vez instalado, el sistema reinicia y aparece la pantalla de login con varias posibilidades:


  • Iniciar sesión con la cuenta del propietario.
  • Iniciar sesión con una cuenta válida de Gmail, la cual quedará cacheada para futuros logins offline.
  • Iniciar sesión con una cuenta de invitado, que al cerrar sesión no quedará rastro en el disco duro local de su actividad.
  • Borrar una cuenta de las creadas, excepto la de invitado y propietario.
  • Conectar a una red wifi, necesario para poder iniciar sesión con cuentas de gmail no cacheadas.

Esta es la configuración desde la pantalla de login:


Y esta una pantalla con ya dos cuentas de gmail cacheadas:


Una vez iniciada sesión carga el Google Chrome como única aplicación:


Este sería menú de opciones de la derecha de la barra de tareas:


Y este el menú de la izquierda de la barra:


Si entramos en la configuración vemos las preferencias usuales del Google Chrome, mas varias opciones extra referidas al escritorio de CloudReady:


Comprobando el rendimiento podemos afirmar que no va nada mal. Al tener un Linux subyacente con drivers actualizados y un escritorio con Chrome y sus extensiones de la Chrome Web Store todo va bastante ligerito teniendo en cuenta la poca potencia de esta máquina.

4. Configuración final e imagen Clonezilla para clonar.

Debajo de esto hay un Linux, por lo que podemos acceder a un terminal para configurar otros aspectos. Con la combinación de teclas CTRL-Alt-T entramos en el terminal, llamado "crosh". Una vez dentro con el comando "shell" entramos en una shell parecida a bash.

Las credenciales por defecto para trabajar son chronos/chronos. Si queremos cambiar la contraseña los pasos son:
# sudo mount -o rw,remount /
# sudo passwd chronos
# sudo mount -o ro,remount /
Y después reiniciamos.

Otra cosa que me resulta útil es desactivar la suspensión. Los pasos, tras entrar en el terminal:
# shell
# sudo su
# disable_verity
Reiniciamos la máquina y repetimos los anteriores comandos una vez. Luego hacemos
# mount -o rw,remount /
# cat /sys/class/dmi/id/product_name >> /usr/share/power_manager/suspend_prevention_models
# restart ui
Aquí tenemos bastante soporte y trucos de configuración adicionales.

Una vez tenemos configurado es el momento de hacer una imagen con clonezilla para instalarlo en otros equipos. La creación de la imagen se inicia normalmente, pero al llear a la partición sda16 siempre aborta con este mensaje:
Starting to clone device (/dev/sda16) to image (-)
Reading Super Block
memory needed: 28369449 bytes
bitmap 7397925 bytes, blocks 2*10485760 bytes, checksum 4 bytes
Calculating bitmap... Please wait... 
extfsclone.c: bitmap free count err, partclone get free:57578015 but extfs get 57553763.
Please run fsck to check and repair the file system
Failed to use partclone program to save or restore an image!
Tras muchas pruebas solo puedo afirmar que CloudReady al apaar el sistema siempre deja la partición sda16 en ese estado, provocando un error al crear la imagen. La solución es arrancar clonezilla y, antes de empezar a hacer nada, irnos a un terminal y teclear:
# sudo fsck -a /dev/sda16
Después de esto ya podemos realizar la imagen sin problema. La restauración funciona correctamente.


Esta semana los camaradas chinos nos han sorprendido lanzando un cohete Larga Marcha 11 desde una plataforma marítima.




Espectacular. Con este sistema resulta mucho mas barato y eficiente el envío de satélites.

miércoles, 5 de junio de 2019

Generación del musthave mínimo para pkgsync

Ya vimos en su día como configurar un musthave lo mas pequeño posible. Como ahora estoy configurando los portátiles y algunos equipos nuevos, si quiero dejarlos preparados para pkgsync en primer lugar tengo que generar un musthave a partir de los paquetes instalados y luego purgarlo de paquetes dependientes para quedarlo con el menor tamaño posible. Por ello he unificado ambos procesos en un único script:
# cat genera-musthave-minimo.sh
#!/bin/bash

FICHERO=$HOME/musthave.$HOSTNAME

echo "Generando lista de paquetes instalados en $FICHERO..."
aptitude show "?installed ?not(?priority(required)) ?not(?essential) ?not(?automatic)" | grep -e ^Package -e ^Paquete | cut -f 2 -d " " | sort > $FICHERO
echo "Obteniendo lista de paquetes multiarch..."
aptitude show "?installed ?multiarch(same)" | grep -e ^Package -e ^Paquete | cut -f 2 -d " " > /tmp/same
sort -u -o /tmp/same /tmp/same

echo "Procesando lista de paquetes multiarch..."
for paquete in `cat /tmp/same`
do
        sed -i 's|$paquete||' $FICHERO
        dpkg -l|grep "$paquete:i386" 1>/dev/null && echo "$paquete:i386" >> $FICHERO
        dpkg -l|grep "$paquete:amd64" 1>/dev/null && echo "$paquete:amd64" >> $FICHERO
done
sort -u -o $FICHERO $FICHERO 

#Ahora vamos a purgar los paquetes dependientes y dejar solo los paquetes "primigenios"
if [ ! -f /usr/bin/apt-rdepends ]
then
    echo "Instalando apt-rdepends"
    apt-get install apt-rdepends
fi

cp /dev/null /tmp/musthave.new
cp /dev/null /tmp/musthave.deps

paquetes_explicitos=$(aptitude search '~i !~M' -F '%p' --disable-columns | sort -u)
for i in $paquetes_explicitos
do
   echo -n "Paquete $i"
   if grep $i $FICHERO > /dev/null
   then
        echo " ->instalado por musthave"
        echo $i >> /tmp/musthave.new        
   else
      if grep $i /etc/pkgsync/mayhave > /dev/null
      then
          echo "  ->instalado por mayhave"
      else
          echo "  ->instalado por musthave.d"
      fi
   fi
done
sort -u -o /tmp/musthave.new /tmp/musthave.new

num=1
total=$(wc -l  /tmp/musthave.new)
for i in $(cat /tmp/musthave.new)
do
   echo "Dependencias de $i: $num/$total"
   ((num++))
   apt-rdepends $i |grep Depends: | awk ' { print $2 } ' | sed "/^${i}$/d" >> /tmp/musthave.deps 
done
sort -u -o /tmp/musthave.deps /tmp/musthave.deps
comm -23 /tmp/musthave.new  /tmp/musthave.deps  > $FICHERO.final

echo "Proceso concluido, musthave en $FICHERO.final"

exit 0
Lanzamos el script y tras un intenso proceso que debemos dejar acabar con paciencia, en el fichero "musthave.$HOSTNAME.final" nos quedan los paquetes básicos para que funcione el pkgsync, que suele quedar en un 15% del musthave que obtenemos con el script original. Una vez generado este musthave en un equipo de pruebas lo podemos copiar a mano o por puppet al resto de equipos similares en su ubicación definitiva dentro de /etc/pkgsync/musthave o /etc/pkgsync/musthave.d/*.


Pedazo artículo el de Daniel Marín sobre el Apolo X, ahora que estamos en el 50 aniversario. Seguro que nos tiene preparada una maravilla sobre el Apolo XI en unas semanas.