FK~

Moje odkazy

Ostatní odkazy

EFF: svoboda blogování
Close Windows
Nenajdete mě na Facebooku ani Twitteru
Rozpad EU
Jsem členem FSF
Jsem členem EFF
There Is No Cloud …just other people's computers.

Java a unixové doménové sokety, FD, systemd a xinetd

vydáno: 4. 6. 2019 16:32, aktualizováno: 19. 7. 2019 10:59

Internet běží sice převážně na TCP/IP, ale v rámci jednoho počítače máme i vhodnější způsoby komunikace. V tomto článku se podíváme na unixové doménové sokety a jejich použití v Javě. Předáme si souborové popisovače (FD) z rodičovského procesu potomkovi a ukážeme si princip socket activation. Nakonfigurujeme si služby v klasickém xinetd i moderním systemd a nakonec propojíme Jetty a Apache HTTPD pomocí unixového soketu.

Logo OpenJDK (průhledné 1)

Unixové doménové sokety (UDS)

UDS jsou formou mezi-procesové komunikace (IPC) v rámci jednoho počítače. Podporují jak proudová spojení (obdoba TCP), tak posílání datagramů (obdoba UDP). Přiřadit si je k vrstvám TCP/IP nebo OSI moc dobře nejde, protože UDS nahrazují jak TCP/UDP (proudy a datagramy), tak i IP (adresaci) a všechno pod ním. (mimochodem na tenhle problém jsem narazil při návrhu nového typu hlaviček pro tcpdump…) Nicméně pro jednoduchost je můžeme považovat za náhradu TCP a UDP v rámci jednoho počítače.

Adresou ve světě UDS je cesta k souboru resp. textový řetězec o maximální délce definované v sys/un.h, což na většině dnešních systémů bude 108 (char sun_path[108]), ale může to být až _POSIX_PATH_MAX tzn. 256 bajtů. Výhodou je, že se nepoužívají nějaká nicneříkající čísla (IP adresy a porty), ale textový řetězec. UDS mohou být i abstraktní (začínají nulovým bajtem a nemají žádnou vazbu na souborový systém).

Zásadním omezením UDS je to, že jsou pouze lokální – lze jimi propojit jen dva procesy běžící na témže počítači (ale můžou běžet pod různými uživateli). Výhody pak jsou:

  • vyšší výkon (nižší režie) oproti síťovým protokolům
  • smysluplné adresy – např. /run/moje-aplikace místo 127.0.0.1:56789
  • předcházení kolizím – už žádné chybové hlášky o tom, že dané číslo portu je obsazení a nelze na něm naslouchat – stačí si zvolit vhodnou jmennou konvenci a do cesty zapracovat třeba PID nebo název instance
  • vyšší bezpečnost resp. snadnější zabezpečení – u síťových protokolů musíme nastavit firewall; pokud aplikace ale naslouchá jen na localhostu, na toto nastavení se často zapomíná a představuje to bezpečnostní riziko (stačí jediná děravá aplikace a na druhé straně pocit, že „porty na localhostu nejsou nikomu zlému přístupné“); oproti tomu přístup k UDS můžeme řídit snadno pomocí klasických souborových přístupových práv
  • víme kdo (který lokální uživatel) se k nám připojuje
  • přes UDS si můžeme posílat souborové popisovače (FD)

UDS jsou jednoduše skvělá věc a pokud nepotřebujete komunikovat po síti, určitě byste o nich měli uvažovat.

Navazování spojení nebo naslouchání z Javy

Java umí komunikovat přes TCP, UDP, SCTP…, ale UDS ve standardní knihovně bohužel nenajdeme (což zřejmě souvisí s tím, že UDS nejsou úplně všude a Java si hodně zakládá na tom, že je multiplatformní – UDS jsou přeci jen specifické pro unixové/posixové/gnu systémy – byť těch je většina). Tradičně se tento nedostatek řeší pomocí knihoven, které staví nad JNI nebo JNA a volají nativní funkce operačního systému. Těchto knihoven existuje hned několik:

a není ani problém si pomocí JNA napsat vlastní nebo si pomocí JNA volat libovolné jiné nativní funkce a udělat si vlastní integraci s operačním systémem. Nicméně o těchto knihovnách už bylo napsáno dost… ve zbytku článku se budu věnovat něčemu přeci jen zábavnějšímu.

Předávání soketu zvenku

UDS (nebo třeba TCP či UDP soket nebo otevřený soubor) si totiž aplikace nemusí vytvářet sama. Tzn. aplikace nemusí vědět, kam se připojit nebo o jaký typ (zda síťový nebo unixový) soketu půjde. O tom, kde se bude naslouchat nebo kam se připojit, rozhodne ten, kdo danou aplikaci spouští (její rodičovský proces), a „podstrčí“ jí již vytvořený soket. Ano, jde o ony v Javě dobře známé a oblíbené principy Inversion of Control (IoC) a Dependency injection (DI), které jsou zde akorát posunuté až na rozhraní mezi Javou a operačním systémem.

Tento přístup je výhodný z několika důvodů:

  • čistější a jednodušší návrh – aplikace dělá jednu věc a dělá ji správně – vše ostatní řeší někdo jiný; je to stejné, jako když v aplikačním serveru aplikace neřeší, k jaké databázi a jak se bude připojovat, kudy bude posílat e-maily nebo jak ověří uživatele atd. – všechny tyto zdroje/závislosti jí nakonfiguruje a poskytne aplikační server
  • pro vytvoření soketu můžou být potřeba zvláštní oprávnění – uživatel, který aplikaci spouští (např. root) může tato oprávnění mít, ale aplikace samotná už běží pod jiným uživatelem, který sám dané sokety vytvářet nemůže – to se týká např. UDS umístěného v adresáři, kam smí zapisovat jen root, změny vlastníka/skupiny nebo třeba naslouchání na privilegovaném TCP či UDP portu (porty nižší než 1024)
  • aplikace samotná nemusí být schopná daný typ soketu vytvořit – viz ta Java a UDS – přesto ho ale umí používat
  • tzv. socket activation – naslouchající soket můžeme vytvořit hned (typicky při startu systému), ale aplikaci spustíme teprve až ve chvíli, kdy se na soket připojí první klient; tím šetříme zdroje a zrychlujeme start systému; zdroje (paměť, CPU) se alokují teprve ve chvíli, kdy jsou potřeba a také je můžeme dealokovat (aplikaci ukončit), když leží delší dobu ladem; pokud např. danou službu voláme jen několikrát za den, spustí se vždy jen na ten krátký okamžik – daný soket (který spotřebovává minimum zdrojů – na rozdíl od běžící aplikace) je ale otevřený po celou dobu a kdykoli se k němu lze připojit (což vede ke spuštění aplikace)

Nevýhodou tohoto přístupu je to, že otevřené sokety předáváme z rodičovského procesu do procesu aplikace pouze v okamžiku, kdy aplikace startuje. Tudíž tento přístup nelze použít pro sokety či soubory, o kterých v době startu nevíme. Pokud by se např. jednalo o aplikaci, která se až během svého života rozhodne, na jakém UDS bude naslouchat nebo na jakou síťovou adresu se připojí, pak předání soketu z rodičovského procesu potomkovi nelze použít (tento scénář lze řešit pomocí posílání FD přes UDS, ale o tom až někdy příště…).

Ačkoli předávání soketů z rodičovského procesu potomkovi může vypadat na první pohled trochu magicky, ve skutečnosti je to přirozené (a naopak je potřeba vyvinout určitou snahu, pokud nechceme, aby se tak dělo).

Souborový popisovač – file descriptor (FD)

FD je identifikátor (číslo) odkazující na otevřený soubor v rámci daného procesu. Ovšem soubor je zde abstraktní pojem – nemusí to být totiž klasický soubor na disku, ale může to být i roura nebo soket (a to jak ten naslouchající, tak navázaná/přijatá spojení, která z něj vzešla). Aneb „všechno je soubor“.

Když např. v Bashi přesměrováváme vstupy a výstupy:

echo ahoj 1> /dev/null 
echo ahoj 2> /dev/null 
echo ahoj 1>&2

tak to 1 a 2 jsou právě souborové popisovače STDOUTSTDERR (standardní a chybový výstup). Když Bash (rodičovský proces) spouští proces programu echo, tak mu nastaví FD 1 resp. 2 tak, aby směřoval na soubor /dev/null, případně jiný soubor, který jsme na příkazovém řádku zadali. Program echo tedy neví, zda posílá svůj výstup na terminál, do souboru nebo třeba někam po síti – pouze zapisuje na FD 1 a nic víc neřeší. Kam je FD 1 napojen, to je starost toho, kdo tento program spustil. (abychom byli úplně přesní: proces dokáže zjistit, jestli jeho vstup nebo výstup jsou terminál – ale musí se sám snažit to zjistit – za normálních okolností se od toho abstrahuje)

Nízkoúrovňové funkce, systémová volání, pro čtení, zápis a další operace pracují právě s těmito identifikátory. Nad těmito funkcemi jsou pak postaveny veškeré vyšší abstrakce a API.

FD jako identifikátor je platný v rámci jednoho procesu tzn. FD 123 v jednom procesu může ukazovat na jiný otevřený soubor nebo soket než FD 123 v jiném procesu. A zároveň různé FD v různých procesech můžou ukazovat na jeden otevřený soubor v systému. Podrobnosti rozebírá např. kniha The Linux Programming Interface:

FD – file descriptors – The Linux Programming Interface (Michael Kerrisk)

Souborové popisovače 0, 1 a 2 mají speciální význam (STDIN, STDOUT a STDERR). Vyšší čísla FD mohou odkazovat na libovolné další otevřené soubory/sokety předávané z rodičovského procesu potomkovi (nebo později otevřené potomkem).

FD si můžeme vypsat jednak ze syntetického souborového systému /proc:

# v jednom terminálu si spustíme proces:
cat > x.txt

# a ve druhém si vypíšeme jeho FD:
ll /proc/$(pidof cat)/fd

# celkem 0
# dr-x------ 2 hacker hacker  0 čen  2 19:08 ./
# dr-xr-xr-x 9 hacker hacker  0 čen  2 19:07 ../
# lrwx------ 1 hacker hacker 64 čen  2 19:08 0 -> /dev/pts/10
# l-wx------ 1 hacker hacker 64 čen  2 19:08 1 -> /home/hacker/x.txt
# lrwx------ 1 hacker hacker 64 čen  2 19:08 2 -> /dev/pts/10

nebo pomocí příkazu lsof:

lsof -p $(pidof cat)

# COMMAND  PID   USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
# cat     1858 hacker  cwd    DIR  253,5     4096 3407873 /home/hacker
# cat     1858 hacker  rtd    DIR  253,3     4096       2 /
# cat     1858 hacker  txt    REG  253,3    35064 2359389 /bin/cat
# cat     1858 hacker  mem    REG  253,3  4253040 1051651 /usr/lib/locale/locale-archive
# cat     1858 hacker  mem    REG  253,3  2030544 1835623 /lib/x86_64-linux-gnu/libc-2.27.so
# cat     1858 hacker  mem    REG  253,3   170960 1835615 /lib/x86_64-linux-gnu/ld-2.27.so
# cat     1858 hacker    0u   CHR 136,10      0t0      13 /dev/pts/10
# cat     1858 hacker    1w   REG  253,5        5 3459695 /home/hacker/x.txt
# cat     1858 hacker    2u   CHR 136,10      0t0      13 /dev/pts/10

V obou případech vidíme, že standardní vstup (0) a chybový výstup (2) jsou napojené na terminál, zatímco standardní výstup (1) směřuje do souboru x.txt. Na výpisu lsof navíc ve sloupečku SIZE/OFF vidíme, že do souboru x.txt bylo zapsáno 5 bajtů (v mém případě řetězec „ahoj“ a znak konce řádku, které jsem zadal do prvního terminálu).

Funkce fork()exec()

Nové procesy se vytvářejí pomocí systémového volání fork(). To způsobí duplikaci původního procesu – včetně otevřených zdrojů jako jsou FD – doslova dojde k jeho rozdvojení (fork). A v tomto novém procesu běží kód původního programu. Pokud chceme spustit jiný program, zavoláme funkci exec(), která načte jinou binárku z disku a nahradí jí původní běžící program. FD však zůstanou zachované, takže nově spuštěný program je má k dispozici. Obvykle se používá fork()exec() dohromady, ale smysl má i volání jen jedné nebo druhé funkce samostatně.

Pokud nechceme, aby se FD předaly nově spuštěnému programu, musíme je po zavolání fork() explicitně zavřít nebo jim nastavit příznak FD_CLOEXEC, který zajistí, že daný FD nepřežije volání exec().

Použití těchto funkcí je poměrně nízkoúrovňová záležitost, kterou často nechceme a nepotřebujeme řešit ručně, a raději pro nastavení FD a spuštění procesu použijeme hotové řešení – tím může být např. Bash nebo různé super-servery a init systémy.

Získání soketu v Javě

Pomocí standardní knihovny Javy jsme schopní se dostat k UDS (nebo TCP či UDP) soketu resp. kanálu předanému z rodičovského procesu, pokud byl tento kanál nastaven na FD 0 – což běžně super-servery a init systémy dělají. V Javě nám stačí zavolat:

Channel inherited = System.inheritedChannel();

// Returns the channel inherited from the entity that created this
// @since 1.5

n.b. Pokud nám soket obsadil FD 0, je zřejmé, že nebudeme moci (snadno) číst ze standardního vstupu – nicméně v případě serverových služeb (démonů) to ani nepotřebujeme (naopak je to nežádoucí a je lepší STDIN explicitně zavřít, aby nemohlo dojít k čekání na čtení ze vstupu, ze kterého stejně nic nepřijde). Na standardní a chybový výstup však zapisovat můžeme (a to se hodí pro logování).

Může nastat několik situací a podle toho se bude lišit obsah proměnné inherited:

  • null – žádný kanál jsme nezdědili – toto je obvyklá situace – na FD 0 máme nějaký běžný vstup, ze kterého lze normálně číst: terminál, soubor či rouru (k tomu používáme System.inSystem.out)
  • instance java.nio.channels.SocketChannel – navázané spojení s jednou konkrétní protistranou (TCP či proudový UDS)
  • instance java.nio.channels.ServerSocketChannel – serverový (naslouchající) soket (TCP či proudový UDS), na kterém je ovšem potřeba teprve zavolat accept() a přijmout spojení (SocketChannel), což za nás v předchozím případě dělal rodičovský proces
  • instance java.nio.channels.DatagramChannel – kanál pro posílání datagramů (UDP nebo datagramový UDS)
  • případně nějaká jiná možnost – zatím se mi nepovedlo nic jiného nasimulovat, ale teoreticky se může v budoucnu objevit i instance něčeho jiného, takže je dobré tuto větev ošetřit a vyhodit výjimku

Pro otestování všech možností jsem napsal jednoduchou ukázku InheritedChannelDemo:

Channel inherited = System.inheritedChannel();

if (inherited == null) {
	// STDIO je napojeno buď na terminál, běžné soubory nebo roury (<,>,|)
	System.err.println("Java: STDIO není zděděný kanál.");
} else if (inherited instanceof SocketChannel) {
	// např. při použití xinetd s wait=no
	// socat stdio unix-connect:test.sock
	try (SocketChannel socket = (SocketChannel) inherited) {
		String zpráva = "Java: zděděný kanál je SocketChannel.\n";
		socket.write(ByteBuffer.wrap(zpráva.getBytes()));
		// System.err.print(zpráva);
	}
} else if (inherited instanceof ServerSocketChannel) {
	// např. při použití xinetd s wait=yes
	// socat stdio unix-connect:test.sock
	ServerSocketChannel server = (ServerSocketChannel) inherited;
	for (int i = 0; i < 3; i++) { // obsloužíme tři spojení a ukončíme se
		SocketChannel socket = server.accept(); // obsluhujeme v jednu chvíli jen jedno spojení
		String zpráva = "Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #" + i + " SocketChannel.\n";
		socket.write(ByteBuffer.wrap(zpráva.getBytes()));
		System.err.print(zpráva);
		socket.close();
	}
} else if (inherited instanceof DatagramChannel) {
	DatagramChannel server = (DatagramChannel) inherited;
	server.configureBlocking(true);
	ByteBuffer datagram = ByteBuffer.allocate(100);
	server.receive(datagram);
	System.err.println("Java: zděděný kanál je DatagramChannel, ze kterého jsme přijali datagram: " + new String(datagram.array()));
	// echo ahoj | socat stdio unix-sendto:test.sock
} else {
	throw new IllegalStateException("Nepodporovaný typ zděděného kanálu: " + inherited.getClass().getName()); // nemělo by nastat
}

Jde o ukázku – normální program by pravděpodobně nepodporoval všechny možnosti, ale např. jen ServerSocketChannel, a šlo by ho tak napojit jak na TCP, tak na proudové UDS. Nebo by podporoval DatagramChannel a šlo by ho napojit na UDP nebo datagramové UDS.

Pokud program spustíme obvyklým způsobem, přes java InheritedChannelDemo, tak to půjde první větví a nic zajímavého se nestane. Program proto musíme spustit tak, aby mu rodičovský proces nastavil FD 0 na nějaký soket. K tomu nám poslouží následující nástroje.

xinetd

xinetd je tzv. super-server, který nahrazuje původní inetd, a dnes již sám patří ke klasice. Super-server naslouchá na požadovaných soketech a v případě potřeby spouští příslušné služby – což, jak jsme si již říkali v úvodu – je výhodné, když potřebujeme šetřit zdroje: služba se díky tomu může spustit jen na chvíli a pak klidně zase ukončit.

xinetd podporuje protokoly TCP a UDP, ale ne UDS (to by tam měl někdo dopsat – byť to tedy bude trochu kolidovat s původním názvem „the Internet daemon“), takže je z hlediska tohoto článku trochu na nic, ale přesto si s ním můžeme náš program zkusit spustit – v Javě je nám totiž jedno, zda jde o TCP či proudové UDS nebo o UDP či datagramové UDS. Projdeme si všechny tři větve našeho javovského kódu.

V Debianu/Ubuntu si xinetd nainstalujeme pomocí apt install xinetd a konfigurační soubory si zakládáme v adresáři /etc/xinetd.d/. V jiných distribucích by to mělo být podobné.

V konfiguraci používáme type = UNLISTED, protože jinak by se xinetd snažil hledat službu podle názvu v /etc/services. Můžeme si změnit číslo portu a je potřeba si upravit cestu ke .class souborům a uživatele, pod kterým program poběží.

Scénář „SocketChannel“

Protože jsme nastavili wait = no, xinetd nejen že vytvořil soket, ale dělá na něm i accept() a přijímá příchozí spojení – a až pro ně spouští jednotlivé instance našeho programu. V Javě pak dostáváme rovnou SocketChannel, ze kterého můžeme rovnou číst a zapisovat do něj (jde o spojení s jedním konktrétním klientem).

service pokus
{
	socket_type     = stream
	protocol        = tcp
	port            = 9999
	type            = UNLISTED

	wait            = no

	user            = hacker
	server          = /usr/bin/java
	server_args     = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo
	instances       = 1
}

Pokud z Javy zapíšeme něco na STDOUT nebo STDERR, tak se to přepošle klientovi.

Scénář „ServerSocketChannel“

Když nastavíme wait = yes, xinetd pouze vytvoří naslouchající soket a předá ho Javě – v ní si pak voláme accept() a přijímáme jednotlivá příchozí spojení. Javovský proces tak není na jedno použití, ale obslouží více klientů.

service pokus
{
	socket_type     = stream
	protocol        = tcp
	port            = 9999
	type            = UNLISTED

	wait            = yes

	user            = hacker
	server          = /usr/bin/java
	server_args     = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo
	instances       = 1
}

Při opakovaných spojeních se nám bude vypisovat:

Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #1 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #2 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #1 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #2 SocketChannel.
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel.
…

z čehož vidíme, že jedna instance obslouží vždy tři spojení (viz zdrojový kód výše), a pak se ukončí – a následně xinetd při čtvrtém spojení spustí novou.

Scénář „DatagramChannel“

Nastavili jsme socket_typeprotocol tak, že naše aplikace bude dostávat datagramy – zprávy nikoli proudová spojení.

service pokus
{
	socket_type     = dgram
	protocol        = udp
	port            = 9999
	type            = UNLISTED

	wait            = yes

	user            = hacker
	server          = /usr/bin/java
	server_args     = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo
	instances       = 1
}

Na datagramy můžeme z Javy také odpovídat (alespoň v případě UDP).

systemd

systemd je moderní init systém (a spousta dalších věcí), který se rozšířil na většinu současných distribucí. Mám k němu svoje výhrady, ale to teď nechme stranou… Kromě jednoduché deklarativní konfigurace má i další hezké vlastnosti jako je tzv. socket activation. A na rozdíl od xinetd podporuje i UDS.

Pro zprovoznění naší služby budeme potřebovat dva soubory v adresáři /etc/systemd/system/:

# /etc/systemd/system/java-pokus.service:
[Unit]
Description=Java: pokus 1
Requires=java-pokus.socket

[Service]
User=hacker
StandardInput=socket
ExecStart=/usr/bin/java -cp '/tmp/cesta-ke-class-souborům' InheritedChannelDemo

[Install]
WantedBy=default.target


# /etc/systemd/system/java-pokus.socket:
[Unit]
Description=Java: pokus 1: soket
PartOf=java-pokus.service

[Socket]
ListenStream=/run/java-pokus

[Install]
WantedBy=sockets.target

Potom si obnovíme konfiguraci a spustíme naslouchání na soketu:

systemctl daemon-reload
systemctl start java-pokus.socket

A vyzkoušíme si socket activation:

# vypíšeme si stav soketu – běží:
systemctl status java-pokus.socket

# vypíšeme si stav služby – neběží:
systemctl status java-pokus.service

# připojíme se na soket jako klient:
socat stdio unix-connect:/run/java-pokus
# což nám vypíše:
#   Java: zděděný kanál je ServerSocketChannel,
#   ze kteréhé jsme přijali spojení #0 SocketChannel.

# a sužba už běží:
systemctl status java-pokus.service

# připojíme se ještě dvakrát:
socat stdio unix-connect:/run/java-pokus
socat stdio unix-connect:/run/java-pokus

# a sužba už zase neběží (viz InheritedChannelDemo.java):
systemctl status java-pokus.service
# systemd ji zase spustí při dalším pokusu o spojení

S pomocí systemd jsme tak zprovoznili službu naslouchající na proudovém UDS napsanou pouze v čisté Javě (jen s její standardní knihovnou).

systemd umí mnoho dalších věcí, lze naslouchat na TCP, UDP, UDS, ale i SCTP, POSIX MQ atd., umí SOCK_STREAM (proudy), SOCK_DGRAM (datagramy) i SOCK_SEQPACKET (podobné jako STREAM, ale zachovávají se hranice mezi pakety), jednu službu lze napojit na několik soketů současně (ta pak dostane více FD a musí si zjistit, který je který) a také umí přijímat spojení (volba Accept=true), takže pro každé spojení se spustí nový proces a dostane FD odkazující na spojení s jedním konkrétním klientem (obdoba wait = no u xinetd) z čehož v Javě bude SocketChannel. Celkově se asi dá říct, že systemd dokáže nahradit všechny funkce xinetd, akorát komplexita tohoto softwaru je bohužel výrazně vyšší (což nesouvisí až tak s tím, že systemd podporuje různé protokoly, ale spíš s tím, že je na něj nabalena spousta jiných nástrojů a komponent).

Ručně psaný zavaděč v C

Pokud potřebujeme jen vytvořit soket a předat jeho FD Javě, tak nepotřebujeme nic tak velkého jako je systemd. K tomu nám stačí pár řádků v céčku. Napsal jsem jako ukázku jednoduchý zavaděč – start.c:

#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>

/**
 * Otevře unixový doménový soket a napojí ho na FD 0 místo STDOUT.
 * V Javě pak tento soket najdeme v System.inheritedChannel()
 * a můžeme na něm zavolat accept() a přijmout příchozí spojení.
 * 
 * TODO: kontrolovat návratové hodnoty
 * 
 * @param argc
 * @param argv parametry javy (tenhle program používáme místo příkazu java)
 * @return 
 */
int main(int argc, char**argv) {

	struct sockaddr_un address;

	char* socket_name = "test.sock"; // TODO: maximální délka (sun_path)
	unlink(socket_name);

	memset(&address, 0x00, sizeof (address));
	address.sun_family = AF_UNIX;
	strncpy(address.sun_path, socket_name, sizeof(address.sun_path));

	int sock = socket(AF_UNIX, SOCK_STREAM, 0); // proud
	//int sock = socket(AF_UNIX, SOCK_DGRAM, 0); // datagramy
	bind(sock, (const struct sockaddr*) & address, sizeof (address));
	listen(sock, 100);

	int newStdOut = dup(0);
	dup2(sock, 0);
	
	dprintf(newStdOut, "Socket je na FD 0 a FD %d\n", sock);
	dprintf(newStdOut, "STDIN přesunut na   FD %d\n", newStdOut);

	execv("/usr/bin/java", argv);
}

Program přeložíme a následně ho použijeme místo příkazu java:

# přeložíme implicitně (bez Makefile):
make start

# spustíme program (místo java InheritedChannelDemo)
./start InheritedChannelDemo

# což to nám vypíše:
#   Socket je na FD 0 a FD 3
#   STDIN přesunut na   FD 4
#   Java: startujeme

Chybí tomu ještě pár věcí, ale základní scénář funguje. Program vytvoří UDS a začne na něm naslouchat, pak zduplikuje STDIN na první volný FD a UDS zduplikuje na FD 0. Následně spustí Javu s parametry příkazové řádky. Nedělá se fork, takže Java v paměti nahradí původní céčkovský program. Dále už to běží jako normální javovský proces, ale ve výpisu procesů ji najdeme pod původním názvem ./start (nepřepsali jsme hodnotu argv[0]) a původním PID (je to pořád ten samý proces).

Proudové UDS funguje dobře, ale u datagramového bohužel zjistíme, že Java zprávu sice přijme, ale má problém s posíláním odpovědí (což u UDP fungovalo viz příklady s xinetd). Patrně se nepočítá s UDS a adresa klienta se chybně interpretuje jako UDP adresa (IP + port), takže na ni nejde poslat odpověď přes UDS. Jako adresa klienta se objevují nesmyslné kombinace typu: 124.127.0.0:1661 nebo 128.127.0.0:39276.

Přístup k vyšším FD v Javě

Použití System.inheritedChannel() má tu nevýhodu, že přijdeme o STDIN – FD 0 je obsazený soketem. U serverových aplikací STDIN většinou nepotřebujeme, ale co když budeme chtít aplikaci napojit na UDS a zároveň číst STDIN? Pokud nám rodičovský proces zduplikoval původní STDIN na jiný FD (viz příklad výše), máme šanci se k němu dostat. Také můžeme chtít předat více soketů (což např. systemd umí: sd_listen_fds_with_names) a tam se vyšším číslům FD už nevyhneme. Mimochodem, StandardInput=socket znamená, že nám systemd dá soket na FD 0 (jinak by byl na FD 3).

Jenže Java je vysokoúrovňový a multiplatformní jazyk, který se nás snaží od operačního systému dost odstínit, takže budeme muset trochu hackovat :-) Samozřejmě by to šlo přes volání nativních funkcí přes JNI nebo JNA, ale pokusíme se to napsat jen v Javě se standardní knihovnou.

Jedno z řešení, na které jsem narazil v diskusích, je: otevřít si v Javě soubor příslušného FD v adresáři /proc/self/fd/. Viz FileDescriptorDemo – můžeme udělat něco jako:

String fd = "/proc/self/fd/4";
System.out.println("Java: čteme z " + fd);
try (FileInputStream fis = new FileInputStream(fd)) {
	int vstup = fis.read();
	System.out.println("Java: načteno: " + vstup);
}

To sice funguje, ale – jak nám napoví strace – dochází k otevření souboru voláním openat() a vytvoření nového FD (17), ze kterého se pak čte místo toho původního (4), a následně se zavře:

openat(AT_FDCWD, "/proc/self/fd/4", O_RDONLY) = 17
fstat(17, {st_mode=S_IFIFO|0600, st_size=0, ...}) = 0
read(17, "f", 1)                  = 1
write(1, "Java: na\304\215teno: 102", 19) = 19
write(1, "\n", 1)                 = 1
close(17)                         = 0

Z výpisu lsof zjistíme, že FD 4 i FD 17 ukazují na stejný soubor:

lsof -p $(pidof start)
# java    10673 hacker    4u   CHR              136,8      0t0        11 /dev/pts/8
# java    10673 hacker   17r   CHR              136,8      0t0        11 /dev/pts/8

Ale není to úplně to, co bychom chtěli – bude nám to fungovat jen v případě, že vstup je terminál, roura (výstup jiného programu) nebo je na vstup přesměrovaný nějaký normální soubor. A ani v těchto případech to není optimální, protože k otevření souboru dojde dvakrát.

Tímhle způsobem nemůžeme pracovat se serverovým (naslouchajícím) soketem ani s navázaným spojením – v takovém případě při pokusu o otevření v Javě dostaneme chybu:

java.io.FileNotFoundException: /proc/self/fd/5 (Takové zařízení nebo adresa neexistuje)

Což je jiná chyba, než když se jen pokoušíme číst neexistující soubor:

java.io.FileNotFoundException: /proc/self/fd/xxx (Adresář nebo soubor neexistuje)

Větší šanci na úspěch máme, když budeme číst skutečně z toho FD, ze kterého číst chceme, a nebudeme se pokoušet znovu otevírat soubor a nový FD. K tomu budeme potřebovat trochu reflexe a drobné porušení pravidel (zavolání privátního konstruktoru):

private static FileDescriptor najdiFD(int fd) throws NoSuchMethodException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
	Constructor<FileDescriptor> c = FileDescriptor.class.getDeclaredConstructor(Integer.TYPE);
	c.setAccessible(true);
	return c.newInstance(fd);
}

Pak můžeme udělat:

int fd = 4;
System.out.println("Java: čteme z FD " + fd);
try (FileInputStream fis = new FileInputStream(najdiFD(fd))) {
	int vstup = fis.read();
	System.out.println("Java: načteno: " + vstup);
}

a čteme skutečně z FD 4, takže nám to bude fungovat i se soketem (přijatým spojením), což si můžeme vyzkoušet tak, že do céčkového zavaděče přidáme accept(sock, NULL, NULL); a v Javě nastavíme fd = 5;. Podobným způsobem bychom se mohli dopracovat i k instanci ServerSocketChannel viz třída InheritedServerSocketChannelImpl, a mít tak možnost přijímat nová spojení na předaném předaném na libovolném FD.

Takové řešení je ale poměrně křehké a pokud bychom se k používání neveřejného API uchýlili, museli bychom svůj program důkladně testovat s každou verzí Javy. Nakonec je tedy lepší si napsat vlastní implementaci (nad JNI nebo pohodlnějším JNA), která bude jen vracet vlastní instance standardních javovských rozhraní (proudy, sokety, kanály…), ale nebude záviset na žádném neveřejném API.

Webové servery na UDS

Dost bylo hackování, teď se podíváme na nějaká hotová řešení, která podporují předávání soketu rodičovským procesem.

Jetty

Jetty je webový server resp. servletový kontejner a obvykle se provozuje tak, že naslouchá na TCP portu 8080 nebo jiném, kde odpovídá na HTTP požadavky. My si ho ale nastavíme tak, aby naslouchal na UDS a odpovídal na HTTP požadavky tam.

Jetty má docela hezkou modulární konfiguraci. Modul se skládá z INI souboru a případných XML nebo i JAR souborů. Výchozím bodem je pak soubor start.ini, ve kterém jsou vyjmenované moduly, které se mají aktivovat, a případně konfigurační parametry ve stylu klíč=hodnota. Pro naše účely si založíme nový modul UDS, což obnáší vytvoření dvou souborů – modules/uds.mod:

[description]
Enables a HTTP connector on unix domain socket (UDS).

[tags]
connector
http

[depend]
server

[xml]
etc/jetty-uds.xml

A soubor etc/jetty-uds.xml, který je kopií původního etc/jetty-http.xml, akorát jsme z něj odmazali parametry jetty.http.hostjetty.http.port a naopak přidali inheritChannel:

<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="Server" class="org.eclipse.jetty.server.Server">
	<Call name="addConnector">
		<Arg>
			<New id="httpConnector" class="org.eclipse.jetty.server.ServerConnector">
				<Arg name="server"><Ref refid="Server" /></Arg>
				<Arg name="acceptors" type="int"><Property name="jetty.http.acceptors" deprecated="http.acceptors" default="-1"/></Arg>
				<Arg name="selectors" type="int"><Property name="jetty.http.selectors" deprecated="http.selectors" default="-1"/></Arg>
				<Arg name="factories">
					<Array type="org.eclipse.jetty.server.ConnectionFactory">
						<Item>
							<New class="org.eclipse.jetty.server.HttpConnectionFactory">
								<Arg name="config"><Ref refid="httpConfig" /></Arg>
								<Arg name="compliance"><Call class="org.eclipse.jetty.http.HttpCompliance" name="valueOf"><Arg><Property name="jetty.http.compliance" default="RFC7230_LEGACY"/></Arg></Call></Arg>
							</New>
						</Item>
					</Array>
				</Arg>
				<Set name="inheritChannel">true</Set>
				<Set name="idleTimeout"><Property name="jetty.http.idleTimeout" deprecated="http.timeout" default="30000"/></Set>
				<Set name="acceptorPriorityDelta"><Property name="jetty.http.acceptorPriorityDelta" deprecated="http.acceptorPriorityDelta" default="0"/></Set>
				<Set name="acceptQueueSize"><Property name="jetty.http.acceptQueueSize" deprecated="http.acceptQueueSize" default="0"/></Set>
				<Get name="SelectorManager">
					<Set name="connectTimeout"><Property name="jetty.http.connectTimeout" default="15000"/></Set>
				</Get>
			</New>
		</Arg>
	</Call>
</Configure>

V souboru start.ini teď vypneme modul HTTP a zapneme náš UDS:

# --module=http
--module=uds

Pokud bychom teď server Jetty spustili jen tak ručně, začal by naslouchat na náhodném TCP portu – to proto, že System.inheritedChannel() by mu nic nevrátil a spadlo by to do výchozího režimu. Když ale Jetty spustíme přes xinetd, systemd nebo náš ručně psaný zavaděč a předáme mu soket na FD 0, Jetty začne naslouchat na něm místo na TCP portu.

Pro spuštění přes systemd poslouží následující konfigurace:

# /etc/systemd/system/jetty.socket:
[Unit]
Description=Jetty
PartOf=jetty.service

[Socket]
ListenStream=/run/jetty
SocketUser=root
SocketGroup=www-data
SocketMode=0660

[Install]
WantedBy=sockets.target


#/etc/systemd/system/jetty.service:
[Unit]
Description=Jetty
Requires=jetty.socket

[Service]
User=hacker
StandardInput=socket
WorkingDirectory=/home/hacker/temp/java-unix-socket/jetty-distribution-9.4.18.v20190429/
ExecStart=/usr/bin/java -jar /home/hacker/temp/java-unix-socket/jetty-distribution-9.4.18.v20190429/start.jar
StandardOutput=syslog
StandardError=syslog

[Install]
WantedBy=default.target

Cesty a uživatele si upravíme podle svého a práva nastavíme tak, aby k soketu mohl jen ten, kdo má. Zde je vidět výhoda deklarativní konfigurace – jednoduše si nastavíme oprávnění. Totéž bychom sice mohli udělat i v případě TCP soketu na localhostu – i tam bychom mohli řídit přístup podle toho, který lokální uživatel se na ten TCP port připojuje, ale je to přeci jen složitější konfigurace na více místech, která se snadněji opomene nebo se v ní udělá chyba (např. službu přesuneme na jiný port, ale konfiguraci iptables/nftables zapomeneme aktualizovat). Navíc firewall pro služby naslouchající jen na localhostu pravděpodobně většina správců vůbec neřeší.

Tomcat

Tomcat je rovněž webový server resp. servletový kontejner a na rozdíl od Jetty ve výchozím stavu naslouchá na několika TCP portech. Nejprve se tedy zbavíme těch, které úplně nutně nepotřebujeme – v souboru conf/server.xml si nastavíme:

<Server port="-1" shutdown="SHUTDOWN">

tím deaktivujeme port, přes který se dá Tomcat vypnout (textovým příkazem SHUTDOWN poslaným třeba přes socat). A dále si zakomentujeme řádek:

<Connector port="8009" protocol="AJP/1.3" redirectPort="8443"/>

Teď už Tomcat naslouchá už jen na jediném portu (8080), což napravíme přidáním useInheritedChannel:

<Connector
	port="8080" protocol="HTTP/1.1"
	useInheritedChannel="true"
	connectionTimeout="20000"/>

Od teď bude Tomcat padat, pokud mu na FD 0 nepředáme soket, na kterém by měl odpovídat na HTTP požadavky. Spustíme si ho tedy pod systemd – zde můžeme vyjít z konfigurace pro Jetty, ve které jen změníme:

WorkingDirectory=/home/hacker/temp/java-unix-socket/apache-tomcat-9.0.20/
ExecStart=/home/hacker/temp/java-unix-socket/apache-tomcat-9.0.20/bin/catalina.sh run

Tomcat se spustí a dokonce i naslouchá na UDS, ale bohužel po připojení prvního klienta spadne na:

04-Jun-2019 19:38:50.720 SEVERE [http-nio-8080-Acceptor-0] org.apache.tomcat.util.net.NioEndpoint.setSocketOptions Error setting socket options
	java.net.SocketException: Operace není podporována
		at sun.nio.ch.Net.setIntOption0(Native Method)
		at sun.nio.ch.Net.setSocketOption(Net.java:334)
		at sun.nio.ch.SocketChannelImpl.setOption(SocketChannelImpl.java:190)
		at sun.nio.ch.SocketAdaptor.setBooleanOption(SocketAdaptor.java:271)
		at sun.nio.ch.SocketAdaptor.setTcpNoDelay(SocketAdaptor.java:306)
		at org.apache.tomcat.util.net.SocketProperties.setProperties(SocketProperties.java:204)
		at org.apache.tomcat.util.net.NioEndpoint.setSocketOptions(NioEndpoint.java:416)
		at org.apache.tomcat.util.net.NioEndpoint.setSocketOptions(NioEndpoint.java:76)
		at org.apache.tomcat.util.net.Acceptor.run(Acceptor.java:115)
		at java.lang.Thread.run(Thread.java:748)

Tomcat se zjevně na UDS soketu snaží nastavit TcpNoDelay, což jaksi nemůže projít. A nepodařilo se mi to opravit ani přidáním tcpNoDelay="false". U Tomcatu jsme tedy s UDS zatím neuspěli. Nicméně si ho můžeme alespoň pustit na privilegovaném portu:

[Socket]
ListenStream=80

Takže to zase tak úplně k ničemu nebylo. Ještě se podívám, co by se s tím dalo dělat, protože úprava Tomcatu, aby podporoval i UDS, by neměla být složitá. Viz Bug 63568.

Apache HTTPD jako reverzní proxy

Máme tedy Jetty nebo jiný HTTP server naslouchající na UDS. Spojení si můžeme vyzkoušet pomocí nástrojů jako socat, ale z běžného WWW prohlížeče se nepřipojíme. Tento náš server (poskytující např. jednu malou službu) můžeme jednoduše začlenit do naší konfigurace Apache na nějaké cestě nebo subdoméně:

<Location /jetty/ >
	ProxyPass unix:/run/jetty|http://localhost/
</Location>

Apache nám tak zprostředkuje komunikaci s Jetty nebo jiným serverem a nepotřebujeme k tomu ani žádné zbytečné TCP porty na localhostu.

Závěr

Dnes jsme si představili obecné výhody unixových doménových soketů (UDS) a předávání souborových popisovačů (FD) rodičovským procesem potomkovi. Ukázali jsme si, že v Javě můžeme s UDS pracovat i bez dodatečných knihoven – ve zkratce:

  • funguje předávání serverového TCP, UDP nebo proudového UDS soketu
  • a předávání jednoho navázaného spojení
  • datagramový UDS soket lze předat a lze přijímat datagramy, ale nejde na ně odpovídat
  • potřebná metoda System.inheritedChannel() je v Javě už od verze 1.5
  • vše ostatní lze realizovat pomocí JNI nebo JNA, a Java tak dokáže všechno, co programy v C, C++ nebo jiných jazycích

Dále jsme se podívali na konfiguraci xinetd a systemd a prakticky si předvedli princip socket activation. Nakonec jsme nakonfigurovali server Jetty tak, aby naslouchal jen na UDS, a zpřístupnili ho přes Apache HTTPD.

Přílohy:

Odkazy a zdroje:

Témata: [GNU/Linux] [hack] [Java] [C]

Komentáře čtenářů


pb, 5. 6. 2019 09:44, +100 [odpovědět]

Parádní článek, moc děkuji.


Franta, 19. 7. 2019 10:57, Tomcat [odpovědět]

Tak v Tomcatu už to taky bude fungovat :-) Viz Allow keeping tcpNoDelay untouched (default).

Přidat komentář

reagujete na jiný komentář (zrušit)
jméno nebo přezdívka
název příspěvku
webová stránka, blog
e-mailová adresa
nápověda: možnosti formátování
ochrana proti spamu a trollům

Náhled komentáře