Mai multe tunele WireGuard active simultan pe macOS

Clientul oficial WireGuard pentru macOS tine un singur tunel activ: cand il pornesti pe al doilea, primul se opreste. Pe iOS asta reflecta o limita reala a sistemului, pe macOS nu — acolo sistemul stie sa tina mai multe sesiuni de tunel in paralel. Am scris patch-urile care ridica restrictia si, mai important, tot ce a mai fost nevoie ca lucrurile sa chiar functioneze: fiecare tunel sa isi primeasca propriul dispozitiv utun, DNS-ul sa nu se calce intre tunele, si endpoint-urile sa fie rescrise cand treci pe alta retea.

Limita e a aplicatiei, nu a sistemului

Punctul unde se decide totul e in TunnelsManager.swift. Cand ceri activarea unui tunel, aplicatia se uita daca exista deja unul care nu e inactiv; daca da, il parcheaza pe cel nou intr-o stare inventata de ea, .waiting, il opreste pe cel activ si abia dupa ce acela ajunge complet jos porneste ce i-ai cerut:

if let tunnelInOperation = tunnels.first(where: { $0.status != .inactive }) {
    tunnel.status = .waiting
    activateWaitingTunnelOnDeactivation(of: tunnelInOperation)
    startDeactivation(of: tunnelInOperation)
    return
}

Codul e comun pentru iOS si macOS, iar singurul loc din tot proiectul unde limita e pusa explicit pe seama sistemului de operare e imprejmuit cu #if os(iOS). Pe macOS nu exista niciun echivalent: fiecare tunel e propriul NETunnelProviderManager, iar sistemul accepta mai multe sesiuni deodata. Restrictia e o decizie de interfata mostenita din iOS, nu o constrangere tehnica.

Ridicarea politicii nu e de ajuns: doua tunele, un singur utun

Aici e miezul problemei, si motivul pentru care incercarile anterioare pareau ca merg dar nu mergeau. Pe macOS, mai multe sesiuni ruleaza in acelasi proces de extensie. Adaptorul isi cauta dispozitivul de tunel scanand descriptorii de fisier ai procesului si luand primul socket de control utun gasit. Cu un singur tunel per proces, alegerea era mereu corecta. Cu doua, al doilea adaptor se ataseaza la dispozitivul primului: ramane fara cititor pe al lui, deci nu trece niciun pachet, in timp ce pe primul se bat doua backend-uri pe acelasi descriptor.

Simptomul e exact cel raportat pe lista de discutii inca din iulie 2021: ambele tunele arata Connected, dar doar unul duce trafic. Cine se uita la starea din interfata crede ca merge; cine se uita la pachete vede altceva.

Fix-ul tine un registru de dispozitive deja revendicate in proces si, dintre cele libere, il alege pe cel care poarta deja adresele tunelului curent — setTunnelNetworkSettings le-a atribuit pana cand porneste backend-ul, deci potrivirea e sigura. Revendicarea se elibereaza la oprire, la esec de pornire si la dealocare.

Hack-ul cu exit(0)

Extensia de retea are pe macOS un ocol pentru un bug Apple vechi (32073323): la oprirea tunelului isi omoara procesul cu exit(0). Cu un singur tunel per proces e inofensiv. Cu mai multe, oprirea unuia le-ar lua pe toate cu el.

Patch-ul numara tunelele pornite in proces si iese doar la ultimul. Numaratoarea acopera si pornirile in curs, nu doar cele terminate: rezolvarea DNS si urcarea interfetei pot dura secunde, iar un tunel oprit intre timp s-ar fi crezut ultimul si ar fi omorat o activare care tocmai se desfasura.

DNS: cine pune mana pe resolver

Cand un tunel are servere DNS in configuratie, aplicatia le instala ca resolver pentru tot sistemul — matchDomains setat pe sirul gol inseamna „toate domeniile". Cu un singur tunel activ e o alegere apararabila. Cu mai multe, tunelele se bat pe resolverul de sistem si castiga, nedeterminist, ultimul pornit.

Am adaugat un comutator per tunel, Split DNS, oprit implicit. Cand il pornesti, serverele DNS ale tunelului sunt folosite doar pentru domeniile lui de cautare — restul interogarilor raman pe resolverul retelei. Sintaxa ramane cea din wg-quick, intrarile care nu sunt adrese IP fiind deja tratate ca domenii:

DNS = 10.0.0.53, corp.example.com

Comutatorul e explicit, si nu implicit, dintr-un motiv de siguranta: pornit automat pentru oricine are domenii in DNS=, ar fi transformat configuratii existente de tunel-total in scurgeri de interogari catre reteaua locala.

O capcana inrudita, care nu tine de patch dar deruteaza pe toata lumea: un tunel cu AllowedIPs = 0.0.0.0/0 si fara DNS= pare ca nu atinge DNS-ul, dar il rupe oricum — nu prin setari, ci prin rutare, fiindca ruta implicita inghite si pachetele catre resolverul din reteaua locala. Pe un tunel-total, DNS= e practic obligatoriu.

Ce se rupe la schimbarea retelei

Ultima problema a aparut abia in folosinta zilnica: inchizi laptopul pe o retea, il deschizi pe alta, si un tunel nu se mai leaga niciodata, desi celelalte revin. Prima ipoteza — adresa ramasa veche dupa re-rezolvare — s-a dovedit gresita, iar log-ul a aratat de ce:

DNS64: mapped vpn.example.com to 198.51.100.7
peer(...) - Failed to send handshake initiation:
  write udp4 0.0.0.0:49722->10.20.30.1:51820: sendto: no route to host

Aplicatia rezolvase corect numele, la adresa publica. Backend-ul trimitea in cu totul alta parte: la adresa interna a serverului. Explicatia e roaming-ul din WireGuard — daca serverul a fost atins candva la adresa lui din reteaua locala, sau prin alt tunel, peer-ul retine acea adresa. Pe alta retea ea nu mai exista, iar tunelul bate in gol pana il opresti si il pornesti manual.

Pe iOS problema nu apare, fiindca la schimbarea retelei aplicatia rescrie endpoint-urile tuturor peer-ilor — ceea ce, ca efect secundar, anuleaza roaming-ul. Pe macOS se facea doar un wgBumpSockets:

#if os(macOS)
if case .started(let handle, _) = self.state {
    wgBumpSockets(handle)
}
#endif

Patch-ul face si pe macOS rescrierea endpoint-urilor dupa o schimbare de retea, re-rezolvand numele acolo unde configuratia are un hostname. Daca reteaua noua nu are DNS functional, se pastreaza ultima adresa cunoscuta, ca sa nu ramana tunelul fara endpoint. Operatia e amanata si comasata, fiindca o singura schimbare de retea produce o rafala de notificari, iar rezolvarea blocheaza coada de lucru.

Cifrele

Masurat in folosinta reala, pe macOS 26, la trecerea intre retele: handshake-uri complete raportate la initieri, inainte si dupa ultimul patch.

TunelInainteDupa
tunel-total (0.0.0.0/0), endpoint cu hostname2 / 13083 / 129
split A, endpoint cu adresa IP11 / 3985 / 129
split B, endpoint cu adresa IP10 / 3480 / 117

Tunelul cu hostname era singurul afectat grav — celelalte doua se legau oricum, cifrele lor mai slabe dinainte venind din ferestrele in care reteaua chiar lipsea. Erorile ramase dupa patch tintesc numai adrese publice, in intervalul dintre pierderea unei retele si urcarea celeilalte, si sunt normale.

Unde e codul

Seria are zece commit-uri, fiecare compilabil separat pentru macOS si iOS, cu comportamentul de pe iOS neschimbat peste tot. A fost trimisa pe wireguard@lists.zx2c4.com, canalul de contributie documentat al proiectului, dar mesajele nu au iesit din coada de moderare a listei. Codul e deschis ca pull request pe oglinda de GitHub a proiectului:

Proiectul wireguard-apple e marcat oficial drept complet si isi cauta un intretinator nou, iar partea de macOS nu a mai fost atinsa din februarie 2023. Pana se schimba asta, patch-urile raman la indemana oricui trece prin aceleasi probleme.

← Toate articolele