Informatique

Gandi (no) bullshit

Sunday, 02 July 2023
|
Écrit par
Grégory Soutadé

Gandi est un acteur bien connu en France pour son activité de "Gestionnaire de nom de domaine" (registrar en Anglais). Enfin, tout du moins par les profils techniques qui cherchent à gérer leurs noms de domaine ! La réputation de la société (fondée en 2000) s'est bâtie sur sa devise "no bullshit" : l'offre commerciale n'est pas la plus avantageuse, mais derrière l'on retrouve des équipes solides techniquement avec un sens éthique développé (de nombreux organismes à but non lucratif sont sponsorisés). Pour se diversifier, ils ont étendus au fil des années leur offre avec des certificats SSL, ainsi que des hébergement virtuels et physiques.

Malheureusement en 2019, Gandi se fait racheter par un fond de capital-investissement (Montefiore Investissements). Il y a une première vague de départs de la part des clients, alors qu'aucune annonce concrète n'est faite (mais parce-que l'on sait tous comment ça va se finir). En 2023, après quatre années stables, nouveau bouleversement avec la fusion entre Gandi et le groupe Néerlandais Total Webhosting Solutions (TWS) pour former Your.Online. Suite à cette fusion, l'ensemble des clients a eu la surprise de découvrir une augmentation générale des tarifs, ainsi que la partie mail va devenir payante (4€ HT/mois/boîte pour l'offre de base). On peut comprendre une augmentation des tarifs vu de l'inflation actuelle (minime pour un .fr (< 0.5€HT/an)). Mais, ne plus avoir ne serait-ce qu'une adresse mail associée à son nom de domaine (qui est une pratique courante dans le milieu) est rédhibitoire pour beaucoup de personnes. Il y a clairement une recherche maximale de rentabilité au détriment des clients et de l'éthique. Tout du moins de la part de la direction, les équipes techniques devant se contenter de suivre.

Pour ma part, je vais encore rester chez Gandi, car leur solution technique tient la route et que je n'utilisais le mail que comme "relai" pour émettre mon courrier (et ne pas tomber dans les filtres anti-spams), gérant moi-même mes serveurs mails. Néanmoins, je dois configurer une nouvelle entrée "PTR" dans le DNS (qui n'est autre qu'un DNS inversé), notamment requis par Gmail. J'en profite donc pour mettre mon script à jour. Si ça ne tient pas sur le long terme, j'utiliserai de nouveau un relai, notamment via Proton Mail qui a l'air fort sympathique.

Le script est disponible ici

#!/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
import socket
import ipaddress

# Config
domain="soutade.fr"
API_KEY = "MY_API_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="@" # Target record to update

# https://www.programcreek.com/python/?CodeExample=get+local+address
def get_ipv6():
    s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
    s.connect(('2001:4860:4860::8888', 1))
    return s.getsockname()[0]

def get_ipv4():
    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:
            return result.group(0).strip()
    # Bad gateway
    elif response.status_code in (502,504):
        exit(0)
    else:
        print("Connexion error")
        response.raise_for_status()
        exit(1)

def update_gandi_record(domain_records_href, target, value):
    # Get recorded IP
    response = requests.get(f'{domain_records_href}/{A_RECORD_NAME}/{target}', headers=headers)

    if (response.ok):
        record = response.json()
    else:
        print("Failed to look for recorded IP")
        if response.status_code != 502: # Bad gateway
            response.raise_for_status()
        exit(1)

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

        print(f'URL {domain_records_href}/{A_RECORD_NAME}/{target}')

        # PUT new IP
        response = requests.put(f'{domain_records_href}/{A_RECORD_NAME}/{target}',
                                headers=headers, json=record)

        if (response.ok):
            print("IP updated")
        else:
            print("something went wrong")
            if response.status_code != 502: # Bad gateway
                response.raise_for_status()
            exit(1)

        return 34 # IP updated return !!

    return 0

def create_gandi_record(domain_records_href, name, _type, value):
    request = {
        'rrset_name':name,
        'rrset_type': _type,
        'rrset_values': [value],
        'rrset_ttl': 300
        }

    response = requests.post(f'{domain_records_href}', headers=headers, json=request)

    if response.status_code == 201:
        return 0
    else:
        print(response)
        return 1

def delete_gandi_records(domain_records_href, _type):
    response = requests.get(f'{domain_records_href}?rrset_type={_type}', headers=headers)

    if (response.ok):
        json_resp = response.json()
        for record in json_resp:
            requests.delete(record['rrset_href'], headers=headers)
        return 0
    else:
        print(response)
        return 1

# Get current IP
current_ip_v4 = get_ipv4()
print(f'Your Current IP is {current_ip_v4}')

# Retrieve domains address
response = requests.get(livedns_api + "domains", headers=headers)
if (response.ok):
    domains = response.json()
else:
    if response.status_code != 502: # Bad gateway
        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"]
ret = update_gandi_record(domain_records_href, 'A', current_ip_v4)

current_ip_v6 = get_ipv6()
print(f'Your Current IP is {current_ip_v6}')

domain_records_href = domains[domain_index]["domain_records_href"]
ret |= update_gandi_record(domain_records_href, 'AAAA', current_ip_v6)

if ret == 34:
    # Delete all PTR records
    delete_gandi_records(domain_records_href, 'PTR')

    # Update PTR v4
    reverse_ip = '.'.join(current_ip_v4.split('.')[::-1])
    ptr_ip = f'{reverse_ip}.in-addr.arpa'
    create_gandi_record(domain_records_href, ptr_ip, 'PTR', f'{domain}.')

    # Update PTR v6
    full_ip = ipaddress.ip_address(current_ip_v6).exploded
    reverse_ip = '.'.join(full_ip.replace(':', '')[::-1])
    ptr_ip = f'{reverse_ip}.in-addr.arpa'
    create_gandi_record(domain_records_href, ptr_ip, 'PTR', f'{domain}.')

exit(ret)

L'IPv6 est arrivée !

Monday, 17 April 2023
|
Écrit par
Grégory Soutadé

Il y a quelques jours, j'ai eu une coupure d'Internet d'environ 5h... De quoi râler auprès de son opérateur ! Perturbation générale de tous les clients. Il vaut mieux que soit ainsi plutôt qu'un incident isolé, potentiellement long à résoudre. Une fois Internet reconnecté, quel plaisir de voir dans l'interface de la box une adresse IPv6 ! Après quelques soucis de configuration réseau, tous les services sont désormais joignables via cet adressage !

Qu'est-ce qu'IPv6 ?

C'est une bonne question, car en général on veut simplement que notre connexion internet fonctionne sans avoir à tripatouiller les tréfonds de la configuration réseau. IPv6 est le "successeur" d'IPv4. Il a été spécifié en 1998 et normalisé en 2017. L'objectif est de dépasser les limitations actuelles des fameuses adresses "IP" (Internet Protocol). Cette adresse est attribuée de façon statique (toujours la même) ou dynamique aux appareils qui se connectent à un réseau ethernet (dont Internet). Elle permet le routage du trafic réseau. Au quotidien, nous ne manipulons pas directement ces adresses, mais plutôt des identifiants textuels (l'adresse d'un site web) pour naviguer. Ces identifiants sont traduits automatiquement en adresses IP par les serveurs DNS (Domain Name Server). La gestion mondiale des adresses est confiée à la société américaine (à but non lucratif) IANA qui dépend de l'ICANN (américaine également). Elle est chargée d'attribuer des blocs d'adresses IP à différents registres régionaux (1 par continent), qui attribuent eux même des sous-ensembles aux registres locaux (la plupart du temps des opérateurs télécom), attribuant individuellement (à leurs clients) une ou plusieurs adresses.

Les adresses IPv4 sont constituées de 4 octets (32 bits), chaque octet ayant une valeur comprise entre 0 et 255 (exemple : 205.196.32.55). Le nombre d'adresses total est donc de ~4 milliards. Cela peut paraître énorme, mais aujourd'hui où tout le monde a un ou plusieurs appareils connectés en même temps, on arrive vite à saturation. Surtout qu'il faut également compter les adresses nécessaires pour les serveurs (web) et les objets connectés. Les adresses IPv6 sont elles composées de 8 octets (128 bits), amenant ainsi le nombre total à plus de 340 sextillions (ou 34 * 10^37). Elles ont une notation différente, utilisant l'hexadécimal en séparant les blocs de deux octets par ":" (exemple : 2003:e8:2705:ac00:15a5:4e85:fe11:47a3). Pour améliorer la lisibilité, on utilise une forme compressée où les 0 en tête sont supprimés (au niveau du second bloc e8 par exemple). On peut également fusionner tous les blocs avec des 0 consécutifs, comme sur cet exemple : 2a02:4780:12::28 qui correspond en réalité à 2a02:4780:0012:000:0000:0000:0000:0028. Malgré cette version condensée, elles restent pourtant beaucoup moins lisibles.

Alors pourquoi ne pas avoir basculé plus tôt ? Le matériel est compatible depuis de nombreuses années, néanmoins IPv6 n'est pas qu'une simple augmentation de la taille des adresses. La norme apporte également des fonctionnalités complexes à implémenter (cf la coupure réseau !). D'autant plus qu'il faut faire cohabiter les deux piles réseau sur un même système durant la phase de transition. Mais, face à la pénurie mondiale d'IPv4 et la pression des institutions, les opérateurs n'ont d'autres choix que de migrer (progressivement). D'après mes premières statistiques (1/2 mois), il y a environ 20% de connexions utilisant IPv6.

IPv6 sur une box Bouygues Telecom (sagem F@st5330b)

Par défaut, le trafic IPv6 entrant est bloqué par le routeur et ce, même si le pare-feu est désactivé. C'est une bonne mesure de protection, mais qui pourra poser problème quand on souhaite exposer des services sur le web. Voici la procédure complète pour ouvrir les ports pour un serveur GNU/Linux (on pourra également passer en politique "faible" pour tout exposer sur toutes les machines, mais cela reste dangereux) :

  1. Désactiver la délégation de préfixe sur la box
  2. Activer le DHCPv6 sur son interface réseau (ici eth0), il faut ajouter une ligne au fichier /etc/network/interfaces :

    iface eth0 inet6 dhcp

  3. Redémarrer le serveur ou uniquement la partie réseau

    sudo service networking restart

  4. Attribuer une IP statique au serveur (n'est actuellement pas pris en compte)

  5. Récupérer la nouvelle adresse

    sudo dhclient -6 eth0

  6. Vérifier que l'IPv6 du serveur correspond à celle de la configuration de la box

    sudo ip a

  7. Ajouter des règles de pare-feu pour tous les services à exposer (utiliser la fonctionnalité "dupliquer" pour aller plus vite)

  8. Configurer le serveur DNS en ajoutant une règle AAAA
  9. (Optionnel) Tester les règles via ce site

Mise à jour automatique du DNS Gandi

C'est un script que j'utilise depuis 5 ans. Le voici mis à jour pour supporter IPv6. Il est disponible ici

#!/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
import socket

# 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

def get_ipv6():
    s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
    # DNS Google
    s.connect(('2001:4860:4860::8888', 1))
    return s.getsockname()[0]

def get_ipv4():
    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:
            return result.group(0).strip()
    # Bad gateway
    elif response.status_code in (502,504):
        exit(0)
    else:
        print("Connexion error")
        response.raise_for_status()
        exit(1)

def update_gandi_record(domain_records_href, target, current_ip):
    # Get recorded IP
    response = requests.get(f'{domain_records_href}/{A_RECORD_NAME}/{target}', headers=headers)

    if (response.ok):
        record = response.json()
    else:
        print("Failed to look for recorded IP")
        if response.status_code != 502: # Bad gateway
            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 new IP
        response = requests.put(f'{domain_records_href}/{A_RECORD_NAME}/{target}',
                                headers=headers, json=record)

        if (response.ok):
            print("IP updated")
        else:
            print("something went wrong")
            if response.status_code != 502: # Bad gateway
                response.raise_for_status()
            exit(1)

        return 34 # IP updated return !!
    return 0


# Get current IP
current_ip = get_ipv4()
print(f'Your Current IP is {current_ip}')

# Retrieve domains address
response = requests.get(livedns_api + "domains", headers=headers)
if (response.ok):
    domains = response.json()
else:
    if response.status_code != 502: # Bad gateway
        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"]
ret = update_gandi_record(domain_records_href, 'A', current_ip)

current_ip = get_ipv6()
print(f'Your Current IP is {current_ip}')
domain_records_href = domains[domain_index]["domain_records_href"]
ret |= update_gandi_record(domain_records_href, 'AAAA', current_ip)

exit(ret)

Libgourou v0.8.1

Saturday, 21 January 2023
|
Écrit par
Grégory Soutadé

Reminder : Libgourou is a free ADEPT protocol implementation (ePub DRM management from Adobe) that helps download ACSM files on Linux system (and remove DRM).

Libgourou v0.8.1 is out now. It's a minor release if we only look at the ChangeLog, but a very intersting one for a lot of aspects. First, the ChangeLog :

  • DRM with encryption key size 192 bits now fully supported by adept_remove
  • New -D option to specify .adept directory
  • Check for user ID before trying to decrypt a file
  • Default ADEPT directory is now /home/USER/.config/adept and can be specified by $ADEPT_DIR environment variable
  • -f option can be omitted and is replaced by last argument (ie: we can do ./acsmdownloader EPUB)
  • Add man pages
  • Add install/unintall and install_headers/uninstall_headers Makefile targets
  • PugiXML is no more statically linked, we use the system level shared library

Except one bug fix which add a major feature and new default path for .adept, all work has been done in Makefile part which is not code related. And this is interesting because it enlight the fact that libgourou is becoming a popular software (535 downloads within 4 months for v0.8), in "concurrence" with Knock (command line) and Calibre's plugin DeACSM (GUI). I get feedback from 5 different peoples that creates a port to MacOS, create packages for Gentoo and OpenSuse (ArchLinux has an outdated package) + a docker version. The reference implementation (utils) is becoming the only implementation and now has to be more user friendly. This is unexpected as, initially, the project has been designed as a library (so the name "lib" gourou) that should have been integrated by embedded devices, and a reference implementation to be used for Linux users. But I didn't expected that so many people were interested by the command line interface. If the project is still growing like that, I have no doubt that people will ask for new nice features, so wait and see !

You can find binary packages (compiled + AppImage) here.

IWLA 0.6

Sunday, 20 November 2022
|
Écrit par
Grégory Soutadé

Capture d'écran IWLA

Almost 3 years since the last news about IWLA. It does not really reflect the continuous development & maintenance of this wonderful tool. But don't worry, version 0.6 is now out ! The main change is the complete move from Python 2 to Python 3, but we may also mention :

  • Users requests are no more saved (except if keep_requests is set) which allow to save a LOT of space
  • A favicon is available
  • Fresh synchronization with AWSTATS data
  • Users need to do at least one hit per viewed page to not be marked as a robot
  • Feed detector has been enhanced
  • Track users plugin has been replaced by filter users which allows to define complex filters
  • Users can be enlighted in all visits page
  • IP can be anonymized (for public statistics)

The full ChangeLog is available here

While working on it, I realized how we can easily extend it. It's a real pleasure comparing to so big one PERL file code of AWSTATS, plus having it modular allows to implement our own rules which makes statistics really more precise. The only issue compared to AWSTATS is that IWLA is only focused on web statistics, but it has been design for it, not for everything related to log parsing !

New : A demo instance (for indefero.soutade.fr) is available here

I also decided to give up the old style branching model with master and dev. Using git and its lightweight branches, it's better to have a model with tags for stable releases and features branch for development. Code is not often updated and it makes no sense to have a master branch updated every 3 years with only one merge commit while dev is living.

I recently had look on concurrence, especially with Matomo and I was really afraid to see how users are tracked ! Everything is managed from pages viewed to cursor moves, user system information retrieval, time spent... All of this generate extra traffic and requires to execute Javascript code to obtain a lot of information about users's environment. But it's not the worst tool as it doesn't use commercial tracking (like Google Analytics) and keep data on webmaster's server and it's certified RGPD compliant. Commercial trackers are really a nightmare for consumer's privacy. Using it, webmaster can obtain really good statistics, but everything is stored on (abroad) commercial servers to create your profile ! Your profile is then sold or used to display you personalized advertising. Unfortunately, almost all websites are using them. In opposite, IWLA requires no cookies, no Javascript, no awful banner. It only parse and analyze log requests from webserver and generate a static HTML report which is the only right way to do !

Virtual gift card in javascript

Sunday, 16 October 2022
|
Écrit par
Grégory Soutadé

For a birthday I wanted to offer a gift card (something to buy online or later). As I don't have a printer, I decided to create a simple web page containing this card. The address has to be flashed by a Qr Code. But, instead of directly display the gift, I wanted to have something that the person has to discover progressively.

With a little bit of javascript and thanks to HTML5 Canvas API, we can do it easily ! The idea is to create a canvas and then fill the blurred gift or a gift paper. When the person click and drag the mouse over the card, it progressively draw the clear gift card.

Here, I hardcoded some values, but it's possible to get them from image details and do it fully dynamic (Canvas API allows to scale drawn pictures in drawImage() method). Some parts of the code is just a copy/paste from Internet (sorry for copyright, I didn't save the link).

Or the same image with a gift paper :

Javascript source code :

    <center>
    <canvas id="canvas" width="400" height="200" style="cursor:url(cursor32.png), auto ;">
    </canvas>
    </center>
    <script>
      var canvas = document.getElementById('canvas');
      var ctx = canvas.getContext('2d');
      var rect = {};
      var drag = false;
      var imageObj = null, image2Obj = null;


      function init() {
          imageObj = new Image();

          // Gift paper version
          imageObj.onload = function () { ctx.drawImage(imageObj, 0, 0); };
          imageObj.src = 'paper.jpg';

          // Blur version
          imageObj.onload = function () { ctx.filter = 'blur(15px)'; ctx.drawImage(imageObj, 0, 0); ctx.filter = 'none'; };
          imageObj.src = 'gift.jpg';

          // Next
          image2Obj = new Image();
          image2Obj.src = 'gift.jpg';

          canvas.addEventListener('mousedown', mouseDown, false);
          canvas.addEventListener('mouseup', mouseUp, false);
          canvas.addEventListener('mousemove', mouseMove, false);
          canvas.addEventListener('touchstart', touchStart, false);
          canvas.addEventListener('touchmove', touchMove, false);
      }

      function drawClearImage(x, y)
      {
          var canvasRect = canvas.getBoundingClientRect();
          rect.startX = x - canvasRect.left - 5;
          rect.startY = y - canvasRect.top - 5;
          if (rect.startX < 0) rect.startX = 0;
          if (rect.startY < 0) rect.startY = 0;

          ctx.drawImage(image2Obj, rect.startX, rect.startY, 40, 40, rect.startX, rect.startY, 40, 40);
      }
      function mouseDown(e) {
          drag = true;
          drawClearImage(e.clientX, e.clientY);
      }

      function mouseUp(e) { drag = false; }

      function mouseMove(e) {
          if (drag)
              drawClearImage(e.clientX, e.clientY);
      }

      function drawClearImageForTouch(x, y)
      {
          var canvasRect = canvas.getBoundingClientRect();
          rect.startX = x - canvasRect.left - 60;
          rect.startY = y - canvasRect.top - 60;
          if (rect.startX < 0) rect.startX = 0;
          if (rect.startY < 0) rect.startY = 0;
          ctx.drawImage(image2Obj, rect.startX, rect.startY, 120, 120, rect.startX, rect.startY, 120, 120);
      }

      function touchStart(e) {
          for (var i=0; i<e.changedTouches.length; i++)
              drawClearImage(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
      }


      function touchMove(e) {
          for (var i=0; i<e.changedTouches.length; i++)
              drawClearImage(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
      }

      //
      init();

    </script>