Shell Scripting: programación con comandos de shell

Álvaro Uría (Fermat - fermat00 AT euskalnet DOT net)

  Después de haber dado mi primer cursillo de verano ("Iniciación a la administración de sistemas GNU/Linux"), y de haber hablado con algunos alumnos del mismo y haber visto las encuestas, me he decidido a hacer este pequeño manual para profundizar un poco más sobre la programación de Shell Scripts.
  Es mi primer manual -así como fue mi primer cusillo ;-P - así que espero ir perfeccionándolo y añadiendo datos al mismo.

NOTA: He usado la shell BASH, porque es con la que empecé y la que está por defecto al instalar nuestro GNU/Linux. Es por esto que podéis conocerla mejor y que sepáis que hay comandos de esta shell que no se encuentran en otras (sh,ksh,..) y por ello os podría no funcionar vuestro script si usaráis otra shell para la ejecución O:-).

Para ejecutar un shell script hay que realizar 2 pasos:

~$ chmod +x script.bash && ./script.bash
Índice
  1. Introducción
  2. Un paseo por nuestro shell
  3. One-liners
  4. Comandos complejos
  5. Ejemplos Varios
  6. Voy acabandoooouuu

Introducción

    Según he visto en las encuestas, los tronchos de historia de lo que sea no los aguanta nadie, así que voy a pasar directamente a la acción }:-)

    El Shell Script está pensado, cuando no sabes Perl (como yo), para hacer más sencilla la administración de tu sistema. Ya sabemos todos los interesados en este manual, que GNU/Linux se administra desde consola (nada de GUI) así que en vez de realizar una tarea 100 veces por semana, mejor te haces un Shell Script y te ayudas del CRONTAB.

    En este manual veremos desde la utilización de comandos básicos, como los que sueles utilizar cuando navegas por tu consola, hasta los comandos que se utilizan típicamente en shell scripts. Si detectáis que me he olvidao de alguna cosa importante o simplemente os gustaría que lo añadiera, no tenéis nada más que escribir a la lista o directamente a mí ;-)

Espero que no hayáis pulsado Av. Pág. . Empezamos..

Un paseo por nuestro shell

    Cuando investigamos sobre nuestro shell, vemos que hay 2 tipos de comandos: básicos y complejos.

Comandos básicos

    Los comandos básicos a los que me refieron, se encuentran en /bin y en /sbin. Para los elegidos que fueron al cursillo que dí, no hay mayor problema, para el resto, en ApuntesCursosDeVerano tenéis enlaces a guías con la explicación de cada comando, e incluso, en vuestra shell, podéis echar un ojo al manual de cada comando...
~$ man <comando>
~$ man ls
~$ man grep
etc.
    Otras variables que podemos manejar bastante son las propias del shell, como son PATH, HOME, y algunas más que veremos más adelante. (Para ver su contenido, podemos usar el comando echo) i.e.: ~$ echo $HOME

La variable PATH

    Yo creo que esta variable es la más importante cuando nos preocupamos de la seguridad a nivel de shell. Me explico: esta variable contiene el camino (o path) para llegar a los comandos que solemos ejecutar en el shell.
~$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/bin/X11:/usr/games
    Esto significa, que cuando ejecutamos un comando -por ejemplo ls - nuestro shell busca si existe ese programa en /usr/local/bin, si lo encuentra lo ejecuta y se acaba ahí la ejecución del comando. Si no lo encuentra, mira en el siguiente camino - /usr/bin - y así hasta el último. Si no está en ninguno es cuando se devuelve el mensaje..
~$ asdfas
bash: asdfas: command not found
    La idea está en que si se modifica esta variable, añadiendo en primer término otro camino, primero se buscará ahí dicho comando. Para saber qué binario se utiliza para la ejecución de dicho comando..
~$ which ls
/bin/ls
    Por esta razón, yo recomendaría que a la hora de utilizar un comando en nuestro sencillo shell script, demos el camino completo, para que al modificar el path, no tengamos problemas (de seguridad entre otros).

    Para modificar este tipo de variables, utilizamos el comando export. De esta forma..

~$ export PATH=/tmp/.233d:$PATH
buscaría los comandos empezando por el directorio /tmp/.233d/, donde un h4x0r podría tener una implementación de los comandos que están en /bin bastante distinta, con sus consecuencias }:-) . Si en nuestros scripts utilizamos el camino completo para la llamada, no necesitaremos utilizar la variable PATH (éste fue un nivel del HackIt de la semana ESIDE 2003).

Otras variables predefinidas

    En este tipo de variables entra la anteriormente citada HOME, que contiende el directorio de trabajo del usuario en cuestión
fermat@uritech:~/shellScriptin$ echo $HOME
/home/fermat
fermat@uritech:~/shellScriptin$ cd
fermat@uritech:~$
    Estas variables se cargan al loguearte, por lo tanto al modificarlas con export, su valor es temporal. Para que guarden un valor habrá que mirar ficheros como /etc/profile o /etc/bashrc

PS1: es la cadena de caracteres que te sale una vez logueado, en consola.

fermat@uritech:~$ echo $PS1
\u@\h:\w\$
donde
\u es el nombre de usuario
\h es el nombre de la maquina
\w es el directorio donde nos encontramos.. suele empezar por ~ que equivale a /home/usuario
PS2: prompt secundario.. en el caso de no acabar de escribir un comando, sale el valor de esta variable en consola..
fermat@uritech:~$ echo $PS2
>
fermat@uritech:~$ for a in $(seq 1 3)
> do
> echo hola
> done
hola
hola
hola
fermat@uritech:~$
SHELL: contiene el path del shell que estamos ejecutando
fermat@uritech:~$ echo $SHELL
/bin/bash
IFS (internal field separators), suele contener un espacio, un tab o nada, como valor. Que te suene esta variable por si te encuentras con ella en algún momento :-OO

Expansión de Variable

    También conocido como sustitución de parámetros. Aquí quiero referirme a los cambios que se producen desde la ejecución de un comando hasta su finalización. Con un ejemplo creo que se va a entender mejor ;-)
fermat@uritech:~/shellScriptin$ ls $HOME
sería como hacer..
fermat@uritech:~/shellScriptin$ ls /home/fermat
a esto me refiero con expansión de variable.

One-liners

    La verdad es que no sé como traducir One-liners. Los manuales que he ido leyendo han sido en inglés y no encuentro mejor manera de explicar en una palabra de qué va esta sección. Sería algo así como scripts de una línea ;-P

    Una de las cosas que me hace mucha gracia en sistemas UNIX-like es las frikadas que puedes hacer en muy pocos caracteres. No sabes lo que quiero decir con frikadas??? paciencia.. ahora va una

Renombrar varios ficheros de una sola vez

    Nos ponemos en situación. Vienes de la EuskalParty con archivos tatu_en_concierto.AVI, todo_macgaiver.AVI.tar.gz, etc. y quieres estandarizar los nombres, con AVI en minúsculas. Siguiendo con el ejemplo, nuestros downloads quedarían tatu_en_concierto.avi, todo_macgaiver.avi.tar.gz y demás.
fermat@uritech:~/ekparty/videos$ \ls *.AVI* | sed 's/\(.*\).AVI\(.*\)/mv & \1.avi\2/' | bash
voy a analizarlo un poco O_O
    La barra inclinada delante de ls es para que se ejecute el comando tal cual, sin hacer uso de algún alias que tenga, como la visualización con colores (alias ls='ls --color') o de los permisos (alias ls='ls -l')

    El comando sed ya vimos en el curso que era para modificar grupos de caracteres completos. Por ejemplo..

fermat@uritech:~$ cat fich
vamos a correjir un error ortográfico que acabo de cometer
fermat@uritech:~$ cat fich | sed s/correjir/corregir/g
vamos a corregir un error ortográfico que acabo de cometer
En el ejemplo friki, todo lo que está delante de AVI será \1 y lo que está después, \2. Con el pipe ('|') conseguimos que ese proceso que realizamos lo ejecute bash.

Encontrar el nombre completo de un ususario

    Conociendo el formato del fichero /etc/passwd, podemos conseguir el nombre de un usuario..
fermat@uritech:~$ grep ^fermat /etc/passwd
fermat:x:1000:1000:Debian Newbie,,,:/home/fermat:/bin/bash
el comando ejecutado nos muestra las líneas en ese fichero que comiencen por la palabra "fermat". Básicamente, el formato de esta línea es el siguiente
nbre_usuario:seUsaPasswdOno:uid:gid:nombre_usuario,mail_usuario,sandesces_usuario,,: dir_usuario:shell_q_usa_el_user
    Para escoger la columna 5, donde se encuentra el nombre completo del usuario..
fermat@uritech:~$ grep ^fermat /etc/passwd | cut -d ":" -f5 | cut -d "," -f1
Debian Newbie

Matar procesos que concuerdan con una expresión regular que busquemos

    En situación.. eMule bajando a saco, la CPU ya no abre ni una simple xterm.. buaaahh!! vamos a la consola y queremos matar todos los procesos que duermen como angelitos..
~$ kill `ps xww | grep "S" | cut -c1-5 | grep -v PID | xargs` 2> /dev/null
o bien..
~$ kill $(ps xww | grep "S" | cut -c1-5 | grep -v PID | xargs) 2> /dev/null
    De esta forma, todos los procesos que tengan el estado "S" (sleep) serán matados (kill por defecto envía una señal SIGKILL [-9] a un proceso). Para entender los pipes, recomiendo quitar todos e ir añadiendo uno a uno, ejecutandolos ;-). En este caso, se listan los procesos que ejecutamos nosotros, entre esos procesos, escogemos los que tengan estado S, y cogemos los primeros 5 caracteres (el pid), nos quedamos con todas las líneas que no contengan la palabra PID, y dejamos lo conseguido en una sola línea, con un espacio de separación entre cada conjunto de caracteres. Los errores que se produzcan no se mostrarán.

NOTA: aconsejo que las pruebas se hagan con el comando echo en vez de kill que, por decirlo de alguna forma, es más inofensivo.

Borrar binarios que corresponden con programas en C

    Situación.. de la noche a la mañana nos hemos dado cuenta que somos unos developers de pro y tenemos un directorio lleno de ficheros del tipo holaMundo.c y su respectivo binario holaMundo a secas. Queremos borrar solamente los binarios de nuestros programas pero no podemos hacer movidas como...
fermat@uritech:~/bancodePruebas$ rm $(ls . | grep -v ".c")
fermat@uritech:~/bancodePruebas$ rm $(ls . | grep -v ".c" | grep -v ".h")
y así un sin fin de posibilidades, porque tenemos un directorio que es un poco caos y tiene muchas cosas diferentes: leemes y Makefiles sin extension, ficheros .pas, .c, .h, .java, .s, etc.

    La idea sería, mirar sin un fichero acaba en .c y de ser así, borraremos el fichero con mismo nombre pero sin extension (tenemos holaMundo.c y entonces borraremos holaMundo)...

fermat@uritech:~/bancodePruebas$ for fich in *; do [ -x $fich -a -f $fich.c ] && echo $fich; done | xargs rm -f
donde "-x" es para comprobar si tiene permisos de ejecución, "-a" equivale a una AND, y "-f" es para comprobar si es un fichero. Estos va a dar como resultado TRUE o FALSE: en caso de que dé TRUE, se escribirá el nombre de dicho fichero - echo $fich - todos los ficheros que queremos borrar los listaremos en una sola línea, gracias a xargs. Forzaremos el borrado con "-f"

NOTA: una vez más, probar el script con una salida echo que será menos dolorosa en caso de fallo.


Comandos Complejos

    Para todo lo que vamos a ver a continuación, utilizaremos un editor de texto, como puede ser uno de los 2 que vimos en el cursillo: VI o MCEDIT. Pero no son los únicos, por si no os gustan. (por cierto, dadle a la i y os dejará de pitar el pc-speaker ;-P)

    Vamos a ver si me explico con todos los detalles posibles sobre todas las posibilidades que hay. Por si alguno no lo sabe todavía, a la hora de hacer scripts en cualquier lenguaje de scripting en unix, se avisa al principio de los ficheros, qué programa va a ejecutar nuestro script. Así, en nuestro caso deberemos poner #!/bin/bash para que sea bash quien ejecute nuestro script (en caso de programar en perl, debiéramos poner #!/usr/bin/perl).

IF-THEN-ELIF-ELSE-FI

#!/bin/bash
if [ condicion ]
 then
     #procesos a realizar
 elif [ condicion ]
  then
     #procesos alternativos
 else
     #mas procesos alternativos
 fi

Expresiones condicionales

    Tipos de comprobaciones que vamos a realizar a ficheros y directorios..
-f engendro: TRUE si engendro es un fichero
-d engendro: TRUE si engendro es un directorio
-r, -w, -x engendro: TRUE si engendro tiene permisos de lectura, de escritura o de ejecución.

-z string: TRUE si la cadena de caracteres es CERO.
-L engendro: TRUE si engendro es un enlace simbólico.
-O fich: TRUE si fich pertenece al usuario que está ejecutando este script
-G fich: TRUE si fich existe y su gid es igual al uid del usuario que ejecuta este script
-S fich: TRUE si fich existe y es un socket

fich1 -nt fich2: TRUE si fich1 es más nuevo que fich2
fich1 -ot fich2: TRUE si fich1 es más antiguo que fich2
fich1 -ef fich2: TRUE si existen ambos ficheros y se refieren al mismo fichero, como por ejemplo fich3
Comparaciones_entre_números, comparaciones_entre_strings
-eq, ==
-ne, !=
-lt, <
-gt, >
-le, <=
-ge, >=

CASE

#!/bin/bash

echo -n "Dame un valor numérico: "; read opt

case $opt in
1)
  echo porque escogiste UNO?
  ;;

5)
  echo por el **** teee...
  ;;

*)
  echo $opt no me vale :\(
  ;;

esac

Bucles

#!/bin/bash

cont=5
while [ $cont -gt 0 ]
 do
      echo vuelta $cont
      cont=$(expr $cont - 1) # hay que dejar espacio entre "-" y "1"
 done
y
#!/bin/bash

for cont in $(seq 1 3)
 do
      echo vuelta $cont
 done

Funciones

#!/bin/bash

function saludar
{
 echo hola
}

saludar

Parámetros

    Cuando trabajemos con parámetros, hay varias formas de gestionarlos..
$0: es la llamada al script. i.e.: ./hola_mundo
$1: es el primer parmátro que se pasa
$2: es el segundo, etc.
$#: es el número de parámetros que se pasan
$@ y $*: son todos los parámetros, que se listan de una vez, en el orden que se han pasado.
$$: pid que tiene nuestro script
$?: es el valor de salida del último comando ejecutado.. en un "exit 1", se devolvería el valor 1. Si todo sale bien, se devuelve el valor 0.
$_: nos da el último argumento que hemos utilizado.
#!/bin/bash

echo hemos pasado $# parámetros
echo la llamada a nuestro script se hizo con $0, y tenemos el número $$ como PID
echo el primero parametro pasado es $1
echo y el segundo, $2
echo el último argumento que hemos utilizado es $_
echo todos los parametros pasados son: $@
echo el parametro enterior acabó con valor de salida $?
echo el último argumento que hemos utilizado es $_

fermat@uritech:~/shellScriptin$ ./parameters.bash hola soy edu feliz navidad
hemos pasado 5 parámetros
la llamada a nuestro script se hizo con ./parameters.bash, y tenemos el número 819 como PID
el primero parametro pasado es hola
y el segundo, soy
el último argumento que hemos utilizado es soy
todos los parametros pasados son: hola soy edu feliz navidad
el parametro enterior acabó con valor de salida 0
el último argumento que hemos utilizado es 0
fermat@uritech:~/shellScriptin$

    A las funciones también les podemos pasar parámetros. Y una cosa que debe quedar clara es que los parámetros de las funciones son distintos a los que se pasan en la llamada al script. Es decir, un $1 en una función representa el primer parámetro pasado a la función, que puede ser sin ningún problema, distinto al primer parámetro pasado al script ;-)

Así..

fermat@uritech:~/shellScriptin$ cat confusionConParametros.bash
#!/bin/bash

function saluda
{
 echo aupa $1
}

echo hola $1
saluda $2
fermat@uritech:~/shellScriptin$ chmod +x confusionConParametros.bash
fermat@uritech:~/shellScriptin$ ./confusionConParametros.bash Juantxo Jótxe
hola Juantxo
aupa Jótxe
fermat@uritech:~/shellScriptin$

Ejemplos Varios

   

Control de conexiones a un servidor

    Más que control, lo llamaría, devastador de conexiones al iRC :-D

    Hay en la empresa una debian a la que se puede conectar todos los empleados de la empresa. Se han enterao que el proxy de la empresa no afecta a este servidor así que se van a conectar a saco para entrar al #ghost. Nosotros, unos newbies en administración de flipar, no sabemos iptables todavía, y tampoco es plan de meter un DROP a todo, así que nos creamos un shell script todo txungo que va a ir a por las conexiones de irc solamente ;-P

    Vamos a usar CRONTAB para arrancar nuestro script cada 5 minutos.

#!/bin/bash

function avisar_user
{
 if [ $# -ge 1 ];then
     for a in $(seq 1 $#)
      do
          uxer=$(/bin/ps ax | grep $1 | xargs | cut -d " " -f2)
          /bin/kill -9 $1
          if [ $uxer != "?" ];then # BitchX por ejemplo, es desde una terminal ;-)
              echo Lo siento, este tipo de conexiones no están permitidas en este momento. > /dev/$uxer 2> /dev/null
          fi
          shift
      done
 else
     echo no hay conexiones al iRC \;-\)
 fi
}

pids=$(/bin/netstat -putan | grep ESTABLISHED | grep ":6667" | awk {'print $6'} | cut -d "/" -f1 | cut -d "D" -f2 | xargs)
avisar_user $pids
    Es un ejemplo bastante malo, lo sé, pero es que no se me ocurre ahora mismo nada que no se os pueda ocurrir. Un script para grabar CD's con cdrecord, haciendo previamente un .ISO, o un envío de correo electrónico, dirigiéndose a cada persona por su nombre (solo cambia esto), oooo.. no sé :-/

Recompilar el Kernel

    Nos solemos acojonar un poco/bastante a la hora de manipular el kernel. Pero eso sólo al principio. Luego cuando vemos todas las opciones que hay, nos acojonamos más ;-DDD

    En #euskal_linux me acaban de dar la idea de crear a modo de ejemplo un shell script que manipule los comandos que ejecutamos para recompilar nuestro kernel (tú tendrás que moverte por el resultado de ejecutar make menuconfig o xconfig). El Shell Script que dé de comer a la boca, para la próxima ;-)

recompilarKernel.bash (EXPERIMENTAL - vamos que funciona, pero el kernel es algo que me gusta manipular más de cerca)

Escanear una lista de servidores

    Nos vamos a ir a tomar un bocata, pero de mientras vamos a dejarle deberes a nuestro engendro. La idea es pasarle a nuestro shell script un fichero con una lista tal que...
~$ cat lista
aktornet.ath.cx
www.borjanet.com
www.txipinet.com
eghost.deusto.es
~$
.. así me aseguro de no ser denunciado por el maligno O:-)

    Iremos ejecutando NMAP contra cada server e iremos logueando la info en un .log y los errores en un .err

#!/bin/bash

if [ $# -ne 1 ];then
    lista="./lista"
else
    lista=$1
fi

function listado
{
for a in $(seq 1 $#)
 do
     echo "Escaneando $1 (no sé mostrará por pantalla)"
     /usr/bin/nmap -sS -P0 -v $1 &> $1.log 2> $1.err
     shift
 done
}

if [ `/usr/bin/whoami` == "root" ];then
 if [ -f $lista ];then
    listado $(/bin/cat $lista | xargs)
 else
    echo "¿qué quieres escanear?"
 fi
else
    echo "Lo siento, estos escaneos hay que hacerlos como ROOT y tú parece que no tienes acceso :-("
fi

Voy acabandoooouuu

    Ahora mismo estoy igual igual que cuando te vas de viaje: "creo que me olvido un montón de cosas..". Bueno, tengo la intención de ir mejorando este documento, tal y como he dicho al principio (añadiendo y modificando a saco), así que no me queda más que dar las gracias a aquellos atrapaos que han conseguido llegar hasta aquí y que si he metido la gamba en algo, o estaría bien incorporar alguna opción más, que me ezzzcribaaa ;-P

Agradecimientos

    Cosas que tiene la vida, conocí el mundo del scripting cuando era aquel enano windowser, ya atrapao por el iRC, y cuan vago que soy, mIRC Scripting me abrió un nuevo mundo con el que dominaba aquellos canales de mala muerte con mis protecciones (no las del iRCap): gracias Khaled q(^_^)p.

    Ya hace 2 años que empecé a curiosear por sistemas unix-like y Txipi acabó reforzándome el distinto mundo del Shell Scripting (comparado con mIRC): thx Thompson & Richie & RMS & Linus & gnu/linux-world-programmers & Txipi q('_')p
    Ya paro ya paro, que entonces tendría que dar las gracias a Bell, a Cerf y a alguno más.

    Ahhh!! bueno, y gracias a borjanet, aKtoR, TxipiNet, y sistemas de deusto en general, por prestarnos sus servers a modo de ejemplo X-DDD

    Y ahora un pequeño flame. Este documento fue escrito desde VI (emacs sux zgor) ;-) y salu2 a fmonkey q luego se pica :-O

Para más información

    He creado una Wiki-Peich para ir añadiendo enlaces a Páginas con ejemplos o lo que sea, relacionado con la programación de Shell Scripts
http://eghost.deusto.es/phpwiki/index.php/ShellScript
    También podemos ver más ejemplos en sirio.deusto.es/garaizar
Notas.

CRONTAB: demonio que ejecuta comandos programados. Lo utilizamos para la automatización de tareas.
GUI: Graphical Unit Interface.


Este documento ha sido escrito por un miembro de e-GHOST y su contenido es libre de ser reproducido en otros medios bajo las condiciones de la Licencia FDL (GNU Free Documentation License).