Netlink intermezzo

Dlho som rozmýšľal, či vôbec má zmysel tento článok zverejniť. Lenže jednou z mojich hlavných motivácii je spísať to, čo som sa naučil. A prečo teda nie aj o tmavých zákutiach Linuxu?

Tento článok je akési intermezzo a v pohode by sa dal preskočiť. O nič neprídete. To, ako Docker manipuluje so sieťou sme si povedali minule. Ak ste však zvedaví ako je to naprogramované, tak pokračujte a možno vám to dá niečo nové zo sveta Linuxu.

Čiže už viem ako kontajner komunikuje s okolitým svetom a že vystavenie portu je vlastne manipulácia so smerovacími tabuľkami. K celému sieťovaniu som doteraz pristupoval z príkazového riadku. Ale ako je to v Dockeri presne naprogramovane? Docker predsa nebude volať iptables?

Povedal som si, žeby bolo fajn sa ponoriť na úplný spodok podpalubia lode Docker. Pod všetky tie abstrakcie, a postupne vychádzať smerom hore.

Podpalubie

Čo je pre mňa podpalubím sieťovej komunikácie v Dockeri? Začnem s tým, čo už viem. Smerovanie, firewall, NAT či sieťový most - všetko sa to odohráva v jadre Linuxu. Ak však chcem vytvoriť napríklad sieťový most, alebo zmeniť smerovacou tabuľku, musí to môj program nejako povedať jadru. Ale ako? Ako to robí napríklad iproute2?

Pamätáte si ešte článok o systémových volaniach? Naivne som si myslel, že stačí nájsť príslušné systémové volanie. Lenže to pri sieťovej komunikácii nie je až také jednoduché. Siete sú komplexná vec. Ak by jadro malo disponovať systémovými volaniami pre každú funkcionalitu, bolo by ich obrovské množstvo. Ako teda povedať jadru že chcem pridať most? Takto som našiel ďalšie rozhranie - Netlink.

Netlink je z pohľadu programátora úplne iný typ rozhrania. Kým systémové volania mi pripomínajú funkcie, Netlink sa podobá viac na message-driven prístup. Je to komunikačný tunel medzi užívateľským priestorom, v ktorom beží aplikácia, a jadrom Linuxu. Týmto tunelom tečú príkazy formou binárnych správ.

Netlink

Takýto prístup má niekoľko výhod. Toto rozhranie je oproti systémovým volaniam asynchrónne. Taktiež je možný multicast. To znamená, že jadro môže posielať správy nielen jednému procesu, ale viacero procesom, ktoré sú v určitej Netlink skupine. Netlink je teda omnoho flexibilnejšie rozhranie.

Celkom jednoduché. Všetko čo musím spraviť, je vytvoriť spojenie z jadrom cez Netlink. Vytvoriť správne naformátovanú správu a odoslať ju. Potom už len prečítať a spracovať odpoveď. Aké jednoduché.

Hor sa do programovania

Aby som sa v tom nezamotal, zoberiem si veľmi jednoduchú situáciu. Chcem programovo zapnúť rozhranie. Čiže si chcem naprogramovať vlastný ip link vlan0 up.

Toto bude chcieť trošku oprášiť znalosti programovania socketov. Netlink je totiž špeciálny druh socketu. Začnem s jeho vytvorením:

sock, err := unix.Socket(
	unix.AF_NETLINK,
	unix.SOCK_RAW,
	unix.NETLINK_ROUTE,
)

if err != nil {
	doError("Nedokazal som vyrobit socket")
}

defer unix.Close(sock)

Podobá sa to na TCP sockety. Lenže namiesto TCP socketov sa vytvára špeciálny AF_NETLINK socket. NETLINK_ROUTE je akýsi klasifikátor a určuje s akým systémom jadra chcem hovoriť.

Socket samotný nestačí. Pri TCP bolo potrebné socketu priradiť adresu a port cez Bind().V prípade Netlinku však žiaden port ani adresa neexistuje. Funkcia Bind() však určuje o aký druh komunikácie pôjde. Či chceme multicast alebo unicast, či chceme mať zvlašť socket pre rôzne vlákna atď.

Mne postačí najjednoduchší unicast a preto mi stačí len nastaviť Family na AT_NETLINK.

err = unix.Bind(sock, &unix.SockaddrNetlink{
	Family: unix.AF_NETLINK,
})

if err != nil {
	doError("Nedokazal som pripravit socket")
}

Teraz, keď už mám vytvorený plne funkčný socket, môžem začať posielať správy. Ale aké? Každá Netlink správa, či prijatá alebo odoslaná, sa skladá z dvoch časti: hlavičky a tzv. payload.

Netlink

Hlavička je dátová štruktúra, ktorá má konštantnú veľkosť a jej parametre sú fixne dané. Slúži hlavne na to, aby Netlink vedel akú správu posielame. Taktiež adresuje akému subsystému ju posielame. V mojom prípade hlavička bude:

length := unix.SizeofNlMsghdr + unix.SizeofIfInfomsg

header := &unix.NlMsghdr{
	Len:   uint32(length),
	Type:  uint16(unix.RTM_NEWLINK),
	Flags: uint16(unix.NLM_F_REQUEST) | uint16(unix.NLM_F_ACK),
	Seq:   1,
}

Hlavička začína celkovou veľkosťou správy. Je to súčet veľkosti hlavičky a payload časti. Potom nasleduje typ a zopár flagov. Tu sa veľmi nedá špekulovať. Možno len že typ RTM-NEWLINK je subsystém v rámci systému NETLINK_ROUTE.

Nasleduje vytvorenie samotného príkazu. Ten je reprezentovaný dátovou štruktúrou IfInfoMsg.

payload := &unix.IfInfomsg{
	Family: unix.AF_UNSPEC,
	Change: unix.IFF_UP,
	Flags:  unix.IFF_UP,
	Index:  ethIndex, // index rozrania ktore chceme zapnut
}

Na identifikáciu rozhrania sa používa jeho poradové číslo. To nastavím ako Index. Parametrami Change a Flags poviem, že toto rozhranie chcem zapnúť.

Teraz bude nasledovať také malé bajtové voodoo. Volajme to serializácia na pole bajtov.

msg := make([]byte, length)
copy(msg[0:unix.SizeofNlMsghdr], (*(*[unix.SizeofNlMsghdr]byte)(unsafe.Pointer(header)))[:])
copy(msg[unix.SizeofNlMsghdr:length], (*(*[unix.SizeofIfInfomsg]byte)(unsafe.Pointer(payload)))[:])

V podstate obe dátové štruktúry header aj payload sú už v pamäti vo forme bajtov. Ja to len potrebujem správne pretypovať. K tomu mi poslúžila temná Go funkcia unsafe.Pointer(). Keďže obe štruktúry sú v pamäti na rôznych miestach, tak ich potrebujem skopírovať tak, aby header a payload tvorili jedno kontinuálne pole bajtov msg. Toto by sa dalo riešiť aj múdrejšie cez jednu štruktúru, ale chcel som využiť oficiálne NlMsghdr a IfInfomsg.

No a nakoniec správu môžem zapísať do pripraveného Netlink socketu, čím ju odošlem do jadra Linuxu.

err = unix.Sendto(sock, msg, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK})
if err != nil {
	doError("Neviem odoslat spravu")
}

Tu by som socket mohol uzavrieť, program skompilovať a spustiť. Prišiel by som však o všetku tu zábavu s načítaním odpovede z Netlinku. Čiže pokračujem prijatím odpovede:

var rb [1024]byte
nr, _, err := unix.Recvfrom(sock, rb[:], 0)
if err != nil {
	fmt.Println("Neviem nacitat odpoved")
}

rb2 := make([]byte, nr)
copy(rb2, rb[:nr])

Tu sa oplatí len pozastaviť nad existenciou rb a rb2. Toto má jednoduchý dôvod. Veľkosť poľa rb je nastavená pomerne štedro na 1Kb. Načítané dáta sú však často menšie. Zoberme si že z Netlinku načítame len 100 bajtov. Preto si vytvorím pole rb2 so správnou veľkosťou a skopírujem do neho prvých nr bajtov z rb.

Takto prijaté dáta v rb2 môžem parsovať na známu štruktúru hlavička + payload.

resp, err := syscall.ParseNetlinkMessage(rb2)
if err != nil {
	doError("Neviem sparsovat odpoved")
}

K tomu mi poslúžila už pripravená funkcia ParseNetlinkMessage(). Trošku prekvapilo, že funkcia je súčasťou syscall. Netreba sa však nechať oklamať. Nie, nejde o systémové volanie.

Potom nasleduje akási deserializácia poľa bajtov v dáta časti na NlMsgerr správu.

errPayload := (*unix.NlMsgerr)(unsafe.Pointer(&resp[0].Data[0]))

Opäť malé bajtové voodoo. Ak v Go získam smerník na prvý bajt poľa, tak tento smerník môžem pretypovať na smerník dátovej štruktúry.

No a nakoniec už môžem spracovať výsledok. V tomto prípade len skontrolujem, či je Error prázdny.

if errMsg.Error != 0 {
	doError("Netlink vratil chybu")
}

Celý kód je dostupný na GitHube.

Skúška správnosti

Ostáva mi overiť si, či som to naprogramoval správne. Malé upozornenie: Netlink vyžaduje root práva. Najprv si vytvorím testovacie virtuálne rozhranie.

root$ ip link add  type veth
root$ ip link
...
7: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 26:d0:06:69:45:7a brd ff:ff:ff:ff:ff:ff
8: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
    link/ether 8a:75:99:69:e4:c2 brd ff:ff:ff:ff:ff:ff

V zozname mi pribudne dvojica rozhraní s indexom 7 a 8 v stave DOWN, čo je presne to, čo potrebujem. Teraz spustím môj kód pre obe rozhrania:

root$ go run main.go 7
root$ go run main.go 8

Program by mal skončiť úspešne a nemali by sme vidieť žiadnu chybovú hlášku. Skúsim sa opäť pozrieť na zoznam rozhraní:

root$ ip link
...
7: veth0@veth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 26:d0:06:69:45:7a brd ff:ff:ff:ff:ff:ff
8: veth1@veth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 8a:75:99:69:e4:c2 brd ff:ff:ff:ff:ff:ff

Vidím že stav rozhraní sa zmenil na UP . Môj kód teda funguje a komunikuje z jadrom cez Netlink.

Ako ladiť Netlink

Samozrejme môj kód nefungoval hneď na prvý pokus správne. Nie je ľahké správne naplniť dátové štruktúry. Taktiež ladenie a riešenie problémov chcelo nájsť si prístup. Mne sa osvedčil starý dobrý strace. Ten vie od určitej verzie zobraziť Netlink správu. Napríklad pre ip link set veth0 up môžem takýto sendmsg.

sendmsg(3, {msg_name={sa_family=AF_NETLINK, nl_pid=0, nl_groups=00000000}, 
msg_namelen=12, msg_iov=[{iov_base={{nlmsg_len=32, nlmsg_type=RTM_NEWLINK, 
nlmsg_flags=NLM_F_REQUEST|NLM_F_ACK, nlmsg_seq=1623753417, nlmsg_pid=0}, 
{ifi_family=AF_UNSPEC, ifi_type=ARPHRD_NETROM, ifi_index=if_nametoindex("veth0"), 
ifi_flags=IFF_UP, ifi_change=0x1}}, iov_len=32}], msg_iovlen=1, msg_controllen=0, 
msg_flags=0}, 0) = 32

Pozornejšie oko v tomto výstupe nájde hlavičku aj payload. Takýto výstup je super, lebo netreba krkolomne dekódovať pole bajtov z Netlinku.

Programovať Netlink je trošku bláznovstvo. Naplniť správne štruktúry nie je jednoduché. Má to však aj svoje výhody. Zrazu pre mňa boli určité časti kódu zrozumiteľnejšie. Pomohlo mi to s ladením a lepšie som chápal určité strace výstupy. Vďaka tomu som si mohol odsledovať, ktorá časť je za čo zodpovedná.

Netlink a Docker

Čo sa týka sieti, tak Docker nastavuje sieťové komponenty práve takto cez Netlink. Pravdou však je, že kód Dockeru to dnes už nerieši priamo ale využíva Go knižnicu vishvananda\netlink. Tá dáva celému Netlinku v Go trochu lepšiu fazónu. Knižnica však nepodporuje všetko, čo Netlink ponúka. Skôr len kopíruje potreby Dockeru.

Pridať sieťový most je teda záležitosť zavolania správnej Go funkcie:

la := netlink.NewLinkAttrs()
la.Name = "docker0"
dockerBridge := &netlink.Bridge{LinkAttrs: la}
err := netlink.LinkAdd(dockerBridge)

Ak by to nejakého fajnšmekra zaujímalo a chcel by na vlastné oči vidieť, ako to táto knižnica robí, tak sa oplatí pozrieť kód funkcie bridgeVlanModify().

Použité a zaujímavé zdroje

<