Az ipset helytelen használata

Egyik éjszaka miközben álmatlanul forgolódtam fura gondolataim támadtak. Az ipset segítségével – többek között – IP címeket tárolhatunk el, amiket utána felhasználhatunk arra, hogy az iptables szabályainkat egyszerűsítsük. De az IP cím is csak 4 bájtnyi adat (legalábbis IPv4 esetén), ami lehet bármi. Szóval akár foghatnánk egy hosszabb szöveget és átkonvertálhatnánk IP címekké, amiket aztán le tudnánk tárolni ipset-ben, így használhatnánk általános kulcs-érték adatbázisként.

Hogy ennek mi haszna lenne a való életben? Valószínűleg semmi, de az ötlet elég érdekesnek hangzott ahhoz, hogy egy kicsit jobban utánamenjek, hátha lehet belőle tanulni valami érdekeset.

Szövegből IP cím

Kezdjük is rögtön az elején, van egy jóképű adathalmazunk, amikből szeretnénk IPv4 címeket csinálni.

'macisajt'

Először is, felszabdaljuk 4 bájtonként, mert ennyi adat fér el egy IP címben.

['maci', 'sajt']

Aztán minden egyes bájtot számmá konvertálunk.

[[109, 97, 99, 105], [115, 97, 106, 116]]

És végül pontokkal összefűzzük a számokat, hogy IP címeket kapjunk.

['109.97.99.105', '115.97.106.116']

Egy kis érdekesség, hogy a maci a román Telekomhoz, a sajt pedig egy indiai kábeltévé szolgáltatóhoz tartozik.

Ha a szöveg hossza véletlenül nem osztható néggyel (Ki az a galád, aki ilyen szövegeket írna?), akkor feltölthetjük nullákkal, amiket a visszaalakítás során majd le kell vágnunk.

'körte'
    => [107, 195, 182, 114, 116, 101]
        => ['107.195.182.114', '116.101.0.0']

Az IP címek tárolása

Erre természetesen az ipset-et fogjuk használni, de már a nevéből gondolhatjuk, hogy lesznek vele problémák. Mivel set-ről van szó, ezért minden elem csak egyszer szerepelhet benne (nem tudjuk a mosimosi szöveget letárolni) és az elemek sorrendjét se garantálja semmi (lehet, hogy sajtmaci fog visszajönni a macisajt helyett).

Alapbeállítások mellett egy ipset-ben 65536 IP címet tárolhatunk, szóval csinálhatjuk mondjuk azt, hogy az IP cím első két bájtját (az pont 65536 különböző érték) sorszámként fogjuk használni és csak a második két bájt lesz az adat. Ezzel ugyan megoldottuk az egyediséget és a sorrendiséget is, de megfeleztük a letárolható adat mennyiségét.

Szerencsére az ipset nem csak címeket tud tárolni, hanem például IP cím és port párosokat. Nagy szerencsénkre portokból is pont 65536 darab van, így használhatjuk azokat a sorrend felállítására míg az IP címben csak az adatot tároljuk.

A gyakorlatban valahogy így nézne ki a macisajt érték letárolása a hutoszekreny kulcshoz:

# ipset create hutoszekreny hash:ip,port
# ipset add hutoszekreny 109.97.99.105,0
# ipset add hutoszekreny 115.97.106.116,1
# ipset list hutoszekreny
Name: hutoszekreny
Type: hash:ip,port
Revision: 5
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 216
References: 0
Number of entries: 2
Members:
109.97.99.105,tcp:0
115.97.106.116,tcp:1

Lássuk a kódot

Az oda-vissza konvertálás nem fog túl nagy meglepetést okozni, korábban már végigmentünk a működésén.

def text_to_ip(text: str) -> List[str]:
    parts = [str(c) for c in text.encode()]
    remainder = len(parts) % 4
    if remainder > 0:
        parts += ['0'] * (4 - remainder)

    addresses = []
    for i in range(0, len(parts), 4):
        addresses.append('.'.join(parts[i:i + 4]))

    return addresses

A szöveget bájtokká szedjük szét, a számokat aztán visszaalakítjuk szöveggé, hogy a join később működjön. Feltöltjük nullákkal, hogy osztható legyen a hossza néggyel és végül négyesével csinálunk belőle egy IP címet.

def ip_to_text(addresses: List[str]) -> str:
    text = []
    for addr in addresses:
        text += [chr(int(c)) for c in addr.split('.')]

    return ''.join(text).strip('\x00')

Visszafelé talán még egyszerűbb is a dolgunk. Az IP címeket visszaalakítjuk szöveggé, aztán levágjuk a felesleges nullákat a szöveg végéről.

A tárolásnál viszont ismét elgondolkodhatunk egy kicsit. A subprocess modullal minden gond nélkül meghívhatjuk az ipset parancsot, de valahogy nem érződik túl elegánsnak, hogy mondjuk egy 400 bájtos érték eltárolásához 100 parancsot kell elindítanunk.

Használhatjuk helyette az ipset-tel együtt szállított libipset-et és a Python ctypes modulját, ami ugyan kicsit bonyolultabb, de cserébe tízszer gyorsabb, mint a subprocess-es megoldás.

Szükségünk lesz valamire, ami megfelelő módon beszélgetni tud a libipset-tel.

from ctypes import cdll, c_int, POINTER, c_char_p, CFUNCTYPE, c_void_p


class IpSet:
    __output = b''

    def __init__(self):
        self.__library = cdll.LoadLibrary('libipset.so.13')
        self.__library.ipset_load_types()
        self.__library.ipset_init.restype = POINTER(c_int)
        self.__ipset = self.__library.ipset_init()
        self.__library.ipset_custom_printf(
            self.__ipset,
            None, None, self.__ipset_print_outfn,
            None
        )

    def __del__(self):
        self.__library.ipset_fini(self.__ipset)

    def run(self, command: List[str]):
        IpSet.__output = b''
        command = ['ipset'] + command

        self.__library.ipset_parse_argv(
            self.__ipset,
            len(command),
            (c_char_p * len(command))(*[
                c_char_p(arg.encode()) for arg in command
            ])
        )

        return IpSet.__output

    @staticmethod
    @CFUNCTYPE(c_int, POINTER(c_int), c_void_p, c_char_p, c_char_p)
    def __ipset_print_outfn(session, p, fmt, outbuf):
        IpSet.__output += outbuf
        return 0

Betöltjük a library-t, meghívunk rajta függvényeket, hogy megfelelően inicializálódjon, itt persze C típusokkal kell zsonglőrködni, úgyhogy ebből még adódik itt-ott egy "kis" extra kód, de végül sikerül megfuttatni a parancsot.

Nem mondom, hogy egyszerű volt összehozni, bele kellett ölni némi időt és energiát, át kellett bogarászni a ctypes dokumentációját, az ipset forráskódjának releváns részeit és persze a Google is sokat segítet, de a végén sikerült kisakkozni, hogy hogyan kell a darabkákat úgy összeilleszteni, hogy ne szálljon el folyamatosan "Segmentation fault"-tal. A dolog működik, de – figyelembe véve a hozzá nem értésemet – nem biztos, hogy ez a helyes megoldás. :)

Innen már sima ügy a kliens megírása a kulcs-érték adatbázisunkhoz.

import re


class IpSetKeyValueStore:
    def __init__(self, ipset: IpSet):
        self.__ipset = ipset
        self.__ip_pattern = re.compile(r'(\d+\.\d+\.\d+\.\d+),.*:(\d+)')

    def __del__(self):
        del self.__ipset

    def get(self, key: str) -> str:
        result = self.__ipset.run(['list', '-output', 'save', key])
        data = self.__ip_pattern.findall(result.decode('utf-8'))

        addresses = [ip for ip, _ in sorted(data, key=lambda x: int(x[1]))]
        return ip_to_text(addresses)

    def set(self, key: str, value: str) -> None:
        self.__ipset.run(['create', '-exist', key, 'hash:ip,port'])
        self.__ipset.run(['flush', key])

        i = 0
        for ip in text_to_ip(value):
            self.__ipset.run(['add', key, f'{ip},{i}'])
            i += 1

    def delete(self, key: str) -> None:
        self.__ipset.run(['destroy', key])

Az ipset támogat timeout-ot, úgyhogy lehetne készíteni lejáró kulcsokat is vagy használhatnánk IPv6 címeket, hogy még több adatot tudjunk eltárolni, de ezt meghagyom házi feladatnak.

Ezen kívül érdemes még megjegyezni, hogy a IP cím hozzáadásnál lehet megadni kommentet is, amivel sokkal egyszerűbb lenne bármilyen adatot tárolni az ipset-ben, de abban úgy hol a móka?

This post is also available in english: Using ipset the wrong way

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre vagy irány a bejegyzéshez tartozó tweet.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha a régi jó dolgokat kedveled, de követheted a blogot Twitteren is.