Több szolgáltatós internetezés VRF-ekkel és eBPF-el

Adott a következő környezet: van egy X Mbit/s sebességű itthoni internet és egy Y Mbit/s sebességű mobilnet megosztva. Ezeket szeretném egyszerre használni de nem failoverre, hanem arra, hogy adott alkalmazások használják az egyiket, mások használják a másikat, ahol pedig az alkalmazás működéséből adódóan ez lehetséges használja egyszerre a kettőt elérve X + Y Mbit/s sebességet. Nem szeretnék viszont ehhez lokális proxy-t használni, az alkalmazások kódját átírni vagy multihomingot támogató eszközt vásárolni. Rövid nézelődás után nem nagyon találtam használható leírásokat a neten ezért elkezdtem utána nézni hogyan lehetne megcsinálni. A továbbiakban bemutatom, hogyan sikerült megvalósítanom ezt a fajta működést, a szükséges parancsokat tartalmazó repóm linkjét is csatolom. 

A fenti környezet úgy állt elő, hogy nem nagyon volt szükség mostanság mobilnetre, nagyrészt itthon pihent a telefon az asztalon. A vezetékes net (wifi routerrel osztva) pedig 100 Mbit/s ami igazából bőven elég mindenre amire használom, mégis érdekelt hogyan lehetne segíteni neki a mobilnettel, ami nagyjából 70-90 Mbit/s. Mikor a viszony réteg egyszerre több kapcsolatot is használ (vannak ilyen alkalmazások) ezek szétosztásával a hálózatok között gyorsulást lehet elérni. Ami kézenfekvő felhasználásként eszembe jutott az a torrent: ha felosztjuk a seedekhez való kapcsolatokat a hálózataink között, akkor lineárisan összeadódnak azok letöltési sebességei. Ebben semmi új nincsen, elég rákeresni a témára és fogunk találni több konfigurációs megoldást is arról, hogyan lehet elérni ezt a működést. A fő problémám a legtöbb megoldással az az elegancia hiánya volt. Ekkor kezdtem gondolkodni, hogyan lehetne megoldani kicsit elegánsabban és bővíthetően ezt a feladatot. Az hogy ez sikerült-e majd az Olvasó eldönti.

Maradjunk a torrentes feladatnál. A torrent kliens megkapja a peerek listáját a trackertől, és nyit mindegyikhez egy kapcsolatot. A pontos protokollt nem ismerem, valószínűleg valami HTTP szerű, a letöltendő fájl egyes darabjait más-más peertől kéri el. A megkapott darabokat ő a bájtfolyam megfelelő helyeire illeszti, amikből végül összeáll a fájl. Ha sok a peer, akkor párhuzamosan sok darab töltődhet párhuzamosan lefelé, így a peerek feltöltési sebessége nem limitál ha elég sokan vannak. Ami még jobb lenne, ha a saját hálózatunk letöltési sebessége sem lenne limitáció. Ehhez viszont az kellene, hogy bizonyos peereket egy másodlagos hálózaton keresztül érjünk el, például megosztott mobilnetről (vagy a szomszéd wifi-jéről). Ha két hálózatunk van, és szerencsés a helyzetünk mert sok seed érhető el, akkor nagy eséllyel mindkettő kapacitását teljesen ki tudjuk használni. Ehhez viszont jó lenne, ha lenne valami támogatás mondjuk a torrent kliens részéről, és hagyná hogy definiáljunk benne hálózatokat (mondjuk a hálózati interfész nevét megadva). Sajnos amiket használok torrent kliensek ilyet nem tudnak, de találtam egyet amiben lehetett. Ez viszont zárt forrású volt (ami különösebben nem zavart) és Java telepítését igényelte (red flag) ezért ki sem próbáltam, ha valakinek van tapasztalata ilyen klienssel arra kíváncsi lennék. Tippre ez annyit csinál, hogy a socket nyitáskor csinál egy setsockopt SO_BINDTODEVICE-ot és mivel nem írta hogy root-ként kellene futtatni, ennél sokkal többet nem tehet. Pedig az alap linuxos működés több hálózat esetén úgy néz ki, hogy az lesz az alapértelmezett kijárat (default gateway) akinek jobb a metrikája. Ha egyszerre van aktív wifi és vezetékes hálózat akkor csak az utóbbin kommunikál. Így hiába is bindolja a megfelelő eszközre a torrent kliens a socketet, annak a forrás IP címe nem az alapértelmezett kijárat tartományába fog esni ezért jó eséllyel még a NAT-olás előtt eldobjuk: továbbra is egy interfészen fog folyni a kommunikáció. Ami megoldást jelenthet, hogy iptables-el nézzük a forrás IP-t és ennek függvényében más alapértelmezett kijárat irányába routeoljuk a csomagot. De így is oda jutunk vissza, hogy támogatás szükséges az alkalmazás oldaláról, ami pl. Transmissionnél nincs meg. 

Ami biztos, hogy bármilyen is legyen a torrent kliens, szinte biztos hogy lesz egy pont amikor csatlakozik a peerhez. Ehhez nyitnia kell egy socketet, esetleg bindolnia kell egy címhez majd kapcsolódnia kell a connect() primitívet felhasználva. Ezeknek a glibc-s rendszerhívásoknak van egy kernel oldali megfelelőjük is. Ha az alkalmazást nem tudjuk/akarjuk módosítani, itt a kernel oldalon kell azt megoldani, hogy még a connect hívása előtt hozzá kell bindolni egy jó címhez/interfészhez a socketet, így a kapcsolódást már azzal kezdeményezi. Az előbb eleganciát emlegettem, a kernel átírása egy alkalmazás kedvéért épp az ellenkező irányba vinné el a megoldást. A jó hír az, hogy ezt nem kell megtenni, mivel egy jó ideje elérhető a linuxban az eBPF ami (már) alkalmas ilyesmire is.

Aki esetleg nem tudja mi az annak pár mondat róla. A BPF a Berkely Packet Filter rövidítése, wireshark/tcpdump használja. Mikor megadjuk a csomagszűréshez a feltételeket, azokból egy bájtkódot fordít, ezt betölti a kernelbe (akár JIT-eli) amit ott futtat a csomagra és dönt a sorsáról (fel kell-e küldeni a usernek). A bájtkódot egy VM hajtja végre (felfogható úgy mint egy JVM a linuxban). Pár éve ez a VM kapott néhány új regisztert és utasítást meg interfészt user-kernel osztott memória használatra és módot biztonságos kernel-beli memória hozzáférésre. Ez az eBPF (extended BPF). Nagy vonalakban úgy működik, hogy a linuxban bizonyos pontokra be lehet tölteni eBPF-ben megírt programot. Ezt a programot egy kicsit korlátozott C-ben is meg lehet írni, clang-el fordítható BPF tárgykódra és ezt a bpf() rendszerhívással lehet betölteni a kernelbe. A bpf() paraméterként megkapja még, hogy pontosan hová is töltse be a kódot. Rengeteg helyre lehet már most is és folyamatosan bővül a lista. Az eBPF program hozzáfér és módosíthatja a kernel memóriaterületeit, de kárt nem tehetünk, mert betöltéskor futtat rá egy ellenőrzést, hogy lehetséges-e olyan hozzáférés ami kiakasztja a kernelt. Ha igen, akkor már a program betöltését sem engedélyezi. 

Az a megoldás körvonalazódik, hogy be kell tölteni egy eBPF programot egy olyan helyre, ami a connect() socket hívás előtt fut le de már a socket létrehozása után. Ebben az eBPF programban a setsockopt mintájára beállítani, hogy melyik interfészt rendelje a sockethez. Ekkor fogunk ott tartani, hogy már nem kell a program forráskódjában semmit módosítani, az eBPF program valamilyen logika mentén - mondjuk random - szétosztja a socketeket a hálózatok között. Viszont ismét visszajutunk oda, hogy egy alapértelmezett kijáratunk van. 

Itt lehet a segítségünkre a VRF. Ez a Virtual Router Function rövidítése, hálózati rétegbeli (layer 3) virtualizáció implementálására találták ki. Én NagyZ-től hallottam róla, főleg datacenterben való felhasználásra szánták. Itt viszont elegánsan fel lehet használni arra, hogy egy adott hálózati interfész forgalmát külön routing tábla irányítsa. Gyakorlatban úgy néz ki, hogy csinálok egy VRF eszközt (simán ip paranccsal) ami egy virtuális hálózati kártyaként jelenik meg a gépen. Tartozik hozzá egy routing tábla, ebben megadok egy alapértelmezett kijáratot (ami a wifi-hez fog tartozni). Végül a wifi interfészt szintén ip paranccsal beállítom a VRF eszköz slave-jének - innét a VRF másik neve, az L3 master device. Innentől minden forgalom, ami ennek a VRF-nek a kontextusában zajlik, az nem az alapértelmezett routing táblát fogja használni (amiben a vezetékes interfész az alapértelmezett kijárat) hanem a wifit. De hogyan döntjük el, melyek legyenek azok az alkalmazások, amik connect()-jére lefusson az eBPF program? Anélkül, hogy elmélyülnék a technikai részletekben és abban, hogyan van ez implementálva a kernelben a kézenfekvő megoldás a cgroup (v2). Azok a programok, amik egy adott cgroup-ban vannak, azokra futtatjuk az eBPF programot, a többiek pedig az alapértelmezés szerint működnek. Több cgroup-ot definiálhatunk és mindegyikre aggathatunk más eBPF programot. Például csinálhatjuk azt, hogy van 3 cgroup, az elsőben lévő processzek wifit használnak, a másikban lévők vezetékest a harmadikban pedig vegyesen mondjuk connect()-enként random egyiket vagy másikat. Sőt lehet olyan eBPF programot írni, ami IP címek vagy TCP/UDP portok alapján dönt a kimenő interfészről. 

Mikor elkezdtem összerakni kipróbáláshoz a megoldás elemeit, szomorúan tapasztaltam hogy eBPF kóból hívott setsockopt (ami a bpf_setsockopt segédfüggvény) nem támogatja a SO_BINDTODEVICE opciót így nem tudom a socketet a VRF-hez bindolni. Készítettem egy patch-et ami ezt orvosolja (https://lkml.org/lkml/2020/5/30/362), sajnos ez csak nemrég került be a linuxba így az itt leírtak csak az 5.8-as verzióval és az ezt követőkkel fognak működni. A kísérlet eredménye egy minimalista, kipróbáláshoz alkalmas kód és hozzá tartozó parancsok, amiket a következő linken érhettek el: https://github.com/SPYFF/ebpf-vrf

Működését kipróbáltam Transmission-nel, kikapcsoltam a speciális hálózati beállításait (DHT, UPnP, stb.) és szépen látszik ahogyan az Ubuntu lemezképfájlt a vezetékes és mobilnetem együttes sebességével tölti.

Hozzászólások

Szep kis projekt, a dobokocka nagyon talalo:)
A VRF viszont Virtual Routing and Forwarding, es szerintem inkabb a kulonfele telephelyeket osszekoto aramkorok miatt jott letre, ahol a szolgaltatonak szuksege van a szeparalt routing tablakra.

torrent

Igazi retro! :D Címoldalra vele! 

trey @ gépház

szerintem nagyon klassz. +SOK vállveregetés... :)

nem rossz megoldás.

én `iptables CONNMARK` és `ip rule fwmark` használatával szoktam ilyen "több gateway"-es megoldást csinálni.

igaz iptables-szel nem tudom, hogy lehet-e cgroup-ra szűrni. ott vagy uid/gid alapján vagy loadbalance-szolva szoktam egyik vagy másik irányba terelni.

Fentebb írták, hogy per-user vagy per-alkalmazás lehet ilyet csinálni. Network namespace is használható lenne ilyesmire, vagyis olyasmit tudok elképzelni, hogy külön namespace-be rakni azokat a programokat amik más átjárót kell használjanak. Viszont ez kicsit több interfész: veth pár, egyik a netns-ben, másik egy bridge-ben, amiben szintén benne a wifi. Meg ezzel nem lehet olyat, hogy egy alkalmazás socketjeit külön hálózatok között osztjuk szét.

mintha lenne valami setsockopt opció amivel meg lehet jelölni egy socket forgalmát nfmark-kal az applikációból és ip rule-lal vagy iptables-szel az alapján szortírozni. csak annyi kell hozzá hogy mondjuk LD_PRELOAD-dal rávenni az alkalmazast hogy tegye rá azt a beállítást a socketre.

megvan, ez az:

       SO_MARK (since Linux 2.6.25)
              Set the mark for each packet sent through this socket (similar to the netfilter MARK target but socket-based).  Changing the mark can be used
              for mark-based routing without netfilter or for packet filtering.  Setting this option requires the CAP_NET_ADMIN capability.
 

mondjuk nem lenne baj ha nem kéne hozzá cap_net_admin

Igen, ez is egy lehetséges megoldás, ehhez nem kell még VRF sem. Az LD_PRELOAD nekem mindig egy hacknek tűnt és emlékeim szerint nem is működik olyan alkalmazásokkal ez a módszer, amik nem dinamikusan linkelik a glibc-t. Ezért használom inkább az eBPF-es progit helyette. Viszont az előnye hogy nem kell hozzá root jog.

A CAP_NET_ADMIN érdekes, nemrég pont bekerült linuxba a CAP_BPF ami egy furcsa állatfaj, mert ha olyasmit csinál az eBPF progi kell hozzá CAP_NET_ADMIN de egyébként nem feltétlenül. Az jó kérdés, hogy mindent levédtek-e rendesen, nincs-e olyan hogy net admin nélkül tud hálózatos dolgokat módosítgatni: https://lwn.net/Articles/820560/

Már írták, de én is csináltam hasonlót policy routinggal. LARTC-ben leírják. Meg nem nevezett okokból több IP címre kellett szétszednem egy host forgalmát.

Nem mondom, hogy magától értetődő, de amit connection mark-al meg tudsz jelölni, azt bele tudod irányítani egy routing table-be, aminek a default gateway-je más és más eszközön lehet. Ahogy látom van cgroup match iptables-ben, tehát az alapján is megoldható.

Kb. így állítottam be:

echo 101 t1 >> /etc/iproute2/rt_tables
echo 102 t2 >> /etc/iproute2/rt_tables
ip route add default via $GW1 dev ppp0 table t1
ip route add default via $GW2 dev ppp1 table t2
ip rule from $IP1 table t1
ip rule from $IP2 table t2
ip rule fwmark 1 table t1
ip rule fwmark 2 table t2
ip route add default scope global  \
   nexthop via $GW1 dev ppp0 weight 1\
   nexthop via $GW2 dev ppp1 weight 1  

iptables –t mangle –A OUTPUT –m conntrack –ctstate NEW –m statistic –mode nth –every 2 –packet 0 –j CONNMARK –set-xmark 1
iptables –t mangle –A OUTPUT –m conntrack –ctstate NEW –m statistic –mode nth –every 2 –packet 1 –j CONNMARK –set-xmark 2
iptables –t mangle –A OUTPUT –restore-xmark # copy connection mark to packet mark
iptables –t nat –A POSTROUTING –m mark –mark 1 –j SNAT –to-source $IP1 –random
iptables –t nat –A POSTROUTING –m mark –mark 2 –j SNAT –to-source $IP2 –random