Python

Gandi Live DNS v5

Thursday, 26 April 2018
|
Écrit par
Grégory Soutadé

La toute récente sortie de Pannous a été l'occasion de créer un nouveau sous-domaine pour héberger le service. Service qui comporte une partie d'authentification, donc obligation de passer par une communication sécurisée (SSL/TLS). Autrefois, la chose était plus aisée, puisque je pouvais générer mes propres certificats et notamment des certificats "wildcards", donc valides pour tous les sous-domaines.

Sauf que je suis passé à Let's Encrypt. Il a donc fallu attendre la sortie de la version 2 du protocole (qui a eu du retard) afin de bénéficier de cette fonctionnalité. Surtout, qu'au passage, le paquet Debian (backport) de certbot a été cassé, ce qui m'a forcé à revenir à une version encore plus ancienne.

Bref, les choses sont maintenants stables et déployées sur les serveurs respectifs. Petit problème néanmoins, la génération d'un certificat wildcard par Let's Encrypt requiert l'ajout d'une entrée DNS (comme challenge). Fatalité, le DNS de Gandi a lui aussi évolué pour passer en version 5. Avec pour principal avantage une mise à jour immédiate des entrées DNS (là où il fallait plusieurs minutes/heures auparavant). Autre nouveauté : l'API Gandi change de format. On oublie l'ancien XML-RPC (ce qui était pratique avec des bindings Python déjà tout faits), pour passer au REST (un peu moins formel).

Mélangeons tout ça pour obtenir un joli cocktail, dont la recette nous est donnée par Sébastien Blaisot qui, pour nous simplifier la vie, a créé des scripts de génération de certificats wildcards via Let's Encrypt. Le code est disponible sur GitHub et supporte le logiciel bind (en local), l'API OVH et la nouvelle API Gandi. Il ne reste plus qu'à cloner le dépôt et lancer la commande magique :

cd certbot-dns-01-authenticators/gandi-livedns
certbot certonly --manual --server https://acme-v02.api.letsencrypt.org/directory\
 --manual-auth-hook $PWD/auth.py --manual-cleanup-hook $PWD/cleanup.py -d '*.soutade.fr'

Et voilà un joli certificat tout frais !

Du coup, je me suis grandement inspiré de son code pour mettre à jour mon script de DNS fallback (serveur de secours via redirection DNS). Avec, en prime, un passage en Python 3 ! À terme, il faudra que j'ajoute le support IPv6.

#!/usr/bin/env python3
# -*- encoding: utf-8 -*-

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import requests
import json
import re

# Config
domain="soutade.fr"
API_KEY = "YOUR-KEY"
livedns_api = "https://dns.api.gandi.net/api/v5/"
dyndns_url = 'http://checkip.dyndns.com/'
headers = {
    'X-Api-Key': API_KEY,
}
A_RECORD_NAME="@" # Record of A type

# Get current IP
current_ip = ''
response = requests.get(dyndns_url)
if response.ok:
    pattern = re.compile('[^:]*(\d+\.\d+\.\d+\.\d+)')
    result = pattern.search(response.text, 0)
    if result == None:
        print("No IP found")
        exit(1) 
    else:
        current_ip = result.group(0).strip()
else:
    print("Connexion error")
    response.raise_for_status()
    exit(1)

print('Your Current IP is %s' % (current_ip))

# Retrieve domains address
response = requests.get(livedns_api + "domains", headers=headers)

if (response.ok):
    domains = response.json()
else:
    response.raise_for_status()
    exit(1)

domain_index = next((index for (index, d) in enumerate(domains) if d["fqdn"] == domain), None)

if domain_index == None:
    # domain not found
    print("The requested domain " + domain + " was not found in this gandi account")
    exit(1)

domain_records_href = domains[domain_index]["domain_records_href"]

# Get recorded IP
response = requests.get(domain_records_href + "/" + A_RECORD_NAME + "/A", headers=headers)

if (response.ok):
    record = response.json()
else:
    print("Failed to look for recorded IP")
    response.raise_for_status()
    exit(1)

print('Old IP : %s' % (record['rrset_values'][0]))

if current_ip != record['rrset_values'][0]:
    record['rrset_values'][0] = current_ip

    # Put updated IP
    response = requests.put(domain_records_href + "/" + A_RECORD_NAME + "/A", headers=headers, json=record)

    if (response.ok):
        print("IP updated")
    else:
        print("something went wrong")
        response.raise_for_status()
        exit(1)

    exit(34) # IP updated return !!

exit(0)

Une version téléchargable est disponible ici.

IWLA 0.4

Monday, 30 January 2017
|
Écrit par
Grégory Soutadé

Capture d'écran IWLA

Une petite version d'IWLA est sortie. Les changements ne sont pas extraordinaires, mais il y a deux corrections de bug qui méritent de paraître. Au menu cette année :

  • Ajout de l'option -p qui permet de ne regénérer que l'affichage (sans la phase d'analyse)
  • Affichage de la bande passante des robots (possibilité de n'afficher que le top 10 pour gagner de la place)
  • Deux bugs corrigés concernant la compression des fichiers (dont un qui pouvait entraîner des corruptions de base de données).

À vos téléchargements !

Photorec stage 2

Thursday, 29 September 2016
|
Écrit par
Grégory Soutadé

Journée de la femme : tu feras ça demain

"J'ai la fleeeeeeeeeeeeeemme" principale excuse de la question : "Est-ce que tu as fait une sauvegarde de tes données ?" Oui, mais en fait non ! Comme je l'indiquais dans cet article, il faut en faire tous les 6 mois/1 an à minima.

Car, quand la carte mère subit un choc qui provoque un court-circuit sur le disque et que le moteur fonctionne en mode très dégradé empêchant de lire correctement les secteurs, et bien c'est un, cinq, dix ans de données qui sont perdues ! Ne parlons même pas de la destruction par l'eau ou le feu qui est irrémédiable. Pire encore : le vol pur et simple !

Dans notre cas, le disque fonctionne en mode dégradé : lecture poussive mais pas impossible (par contre il refuse de se faire monter). Trois options s'offrent alors :

  • Envoyer le disque chez une entreprise spécialisée qui va réaliser une récupération parfaite (sauf destruction du disque) : dans les 800€-1000€
  • Copie du disque par dd, puis tentative de montage/récupération
  • Tentative de récupération directe par photorec

J'ai choisi d'utiliser la dernière option (le disque ne m'appartient pas). Résultat, il a fallu 10 jours pour l'analyse des quelques 500Go. Photorec fait une lecture secteur par secteur et tente de retrouver la structure des fichiers qu'il connaît (les fichiers multimédias sont bien reconnus). C'est le genre de logiciel qui sauve des vies !

Néanmoins, les méta données sauvegardées dans le système de fichier (nom, emplacement, date) ne sont pas restaurées. On se retrouve donc avec des tas de fichiers de type : recup_dir.x/fXXXXXXX.zzz qu'il faut trier et renommer à la main. Pour effectuer un pré traitement de cet amas, j'ai écrit un petit script Python Photorec stage 2, chargé de la seconde étape d'une récupération photorec. Initialement, il ne devait détecter que les fichiers MS Office et Open Office à partir d'un fichier zip, mais au final il en fait bien plus.

Fonctions principales :

  • Détection des fichiers MS Office et Open Office à partir des fichiers .zip + détection de la date de création
  • Lecture des meta données ID3 des fichiers MP3 pour y retrouver le nom
  • Lecture des meta données EXIF des fichiers JPG pour y retrouver la date de création
  • Filtre sur les extensions (par liste blanche ou liste noire)
  • Filtre sur la taille des fichiers

Voilà de quoi dégrossir le travail (particulièrement efficace pour regrouper les photos d'un même album). Le tout est disponible sur ma forge avec une licence GPL v3.

Astuce IWLA : extraire les 10 meilleurs articles

Monday, 08 August 2016
|
Écrit par
Grégory Soutadé

Après avoir ouvert le champomy pour les 6 ans du blog, je reviens rapidement sur IWLA car j'aime m'auto extasier sur ce petit outil. L'idée de base était de remplacer AWSTATS par quelque chose de plus facilement "hackable", ce qui est chose faite. Si on rajoute la concision du langage Python par dessus, on obtient un script du genre :

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import argparse
import gzip
import pickle
import re
import operator

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Statistics extraction')

    parser.add_argument('-f', '--file', dest='file',
                        help='Comma separated IWLA databases')

    args = parser.parse_args()

    blog_re = re.compile(r'^.*blog\.soutade\.fr\/post\/.*$')

    big_stats = {}

    for filename in args.file.split(','):
        with gzip.open(filename, 'r') as f:
            print 'open %s' % (filename)
            stats = pickle.load(f)
            print 'unzipped %s' % (filename)
            top_pages = stats['month_stats']['top_pages']
            for (uri, count) in top_pages.items():
                if not blog_re.match(uri):
                    continue
                big_stats[uri] = big_stats.get(uri,0) + count
            print 'analyzed %s' % (filename)

    print '\n\nResults\n\n'

    for (uri, count) in sorted(big_stats.items(), key=operator.itemgetter(1), reverse=True)[:10]:
        print '%s => %d' % (uri, count)

Que fait-il ? Il va tout simplement appliquer un filtre sur les pages du blog qui concernent les articles pour en extraire les 10 les plus consultées. Ce qui me fait gagner du temps pour mon bilan annuel !

Bien sûr, on peut créer des tas d'outil indépendants qui vont extraire et manipuler les données pour les mettre en forme, le tout avec une facilité déconcertante. Mieux encore, créer un plugin pour l'intégrer directement dans la sortie HTML quand ceci est nécessaire !

[TIPS] Django : How to select only base classes in a query (remove subclasses)

Monday, 04 July 2016
|
Écrit par
Grégory Soutadé

This was my problem for Dynastie (a static blog generator). I have a main super class Post and a derived class Draft that directly inherit from the first one.

class Post(models.Model):
    title = models.CharField(max_length=255)
    category = models.ForeignKey(Category, blank=True, null=True, on_delete=models.SET_NULL)
    creation_date = models.DateTimeField()
    modification_date = models.DateTimeField()
    author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
    description = models.TextField(max_length=255, blank=True)
    ...

class Draft(Post):
    pass

A draft is a note that will not be published soon. When it's published, the creation date is reset. Using POO and inheritance, it's quick and easy to model this behavior. Nevertheless, there is one problem. When I do Post.objects.all() I get all Post objects + all Draft objects, which is not what I want !!

The trick to obtain only Post is a mix with Python and Django mechanisms called Managers. Managers are at the top level of QuerySet construction. To solve our problem, we'll override the models.Model attribute objects (which is a models.Manager).

Inside inheritance

To find a solution, we need to know what exactly happens when we do inheritance. The best thing to do, is to inspect the database.

CREATE TABLE "dynastie_post" (
    "id" integer NOT NULL PRIMARY KEY,
    "title" varchar(255) NOT NULL,
    "category_id" integer REFERENCES "dynastie_category" ("id"),
    "creation_date" datetime NOT NULL,
    "modification_date" datetime NOT NULL,
    "author_id" integer REFERENCES "auth_user" ("id"),
    "description" text NOT NULL);

CREATE TABLE "dynastie_draft" (
    "post_ptr_id" integer NOT NULL PRIMARY KEY REFERENCES "dynastie_post" ("id")
);

We can see that dynastie_draft has a reference to the dynastie_post table. So, doing Post.objects.all() is like writing "SELECT * from dynastie_post" that includes Post part of drafts.

Solution 1 : Without altering base class

The first solution is to create a Manager that will exclude draft id from the request. It has the advantage to keep base class as is, but it's not efficient (especially if there is a lot of child objects).

class PostOnlyManager(models.Manager):
    def get_queryset(self):
        query_set = super(PostOnlyManager, self).get_queryset()
        drafts = Draft.objects.all().only("id")
        return query_set.exclude(id__in=[draft.id for draft in drafts])

class Post(models.Model):
    objects = PostOnlyManager()

class Draft(Post):
    objects = models.Manager()

With this solution, we do two requests at each access. Plus, it's necessary to know every sub class we want to exclude. We have to keep the BaseManager for all subclasses. You can note the use of only method to limit the query and de serialization to minimum required.

Solution 2 : With altering base class

The solution here is to add a field called type that will be filtered in the query set. It's the recommended one in the Django documentation.

class PostOnlyManager(models.Manager):
    def get_query_set(self):
        return super(PostOnlyManager, self).get_queryset().filter(post_type='P')

class Post(models.Model):
    objects = PostOnlyManager()
    post_type = models.CharField(max_length=1, default='P')

class Draft(Post):
    objects = models.Manager()

@receiver(pre_save, sender=Draft)
def pre_save_draft_signal(sender, **kwargs):
    kwargs['instance'].post_type = 'D'

The problem here is the add of one field which increase database size, but filtering is easier and is done in the initial query. Plus, it's more flexible with many classes. I used a signal to setup post_type value, didn't found a better solution for now.

Conclusion

Depending on your constraints you can use the solution 1 or 2. These solutions can also be extended to a more complex filtering mechanism by dividing your class tree in different families.