Telekommunikációs hálózatok > Gyakorlati anyag >
Socket programozás
Socket programozás
Mi is az a Socket?
Azt már hallhattuk, hogy a Unix rendszereken gyakorlatilag minden egy fájl.
Amikor valamilyen I/O műveletet szeretnénk csinálni, például írni vagy olvasni egy fájlt, akkor azt
egy fájlleírón (file descriptor) keresztül tudjuk megtenni. A file descriptor egyszerűen egy nyitott
fájlhoz társított egész szám. Viszont ez a nyitott fájl lehet egy pipe, sor, hálózati kapcsolat,
terminál vagy a valódi lemezen tárolt fájl is. Amikor kommunikálni szeretnénk a hálózaton keresztül,
egyszerűen egy socket() rendszerhívást kell meghívni, ami visszatér egy socket leíróval, amellyel
már tudunk is a hálózatra írni vagy olvasni.
Példák:
Nézzünk pár példát, hogy miket is lehet csinálni a socket könyvtárral, mielőtt
belemennénk, a socketek típusaiba:
A gethostname() függvénnyel visszakaphatjuk a rendszerünk nevét. A
gethostbyname() függvény segítségével megkaphatjuk egy host ip címét. Jelen esetben a
www.example.org szerver ip címét kérjük le. A gethostbyname_ex() függvény egy tuple-ben visszaadja a
host nevét, ha vannak alias nevei a hostnak, akkor azt is és az összes interface-ének a címét.
Hasonlóan működik a gethostbyaddr() is, csak ip cím alapján szerzi meg ezeket az információkat.
Példa:
Az eddig megszerzett tudással megtudhatjuk, akár a saját gépünk összes
interface-ének ip címét is.
Mielőtt belemennénk a socketek típusaiba, előtte tisztáznunk kell, hogy hogyan is
azonosítjuk, hogy kinek is küldjük az adatokat. Nyilván az ip címmel be tudjuk azonosítani, viszont
a cél gép még nem fogja tudni ennyiből, hogy melyik alkalmazásnak/folyamatnak kell küldeni a
csomagokat. Ezért használjuk a szállítási réteg portjait. Tehát a végpontok azonosításához 5 dolog
kell: forrás ip, forrás port, cél ip, cél port, (szállítási rétegbeli) protokoll (TCP/UDP).
Milyen portjaink vannak?
Vannak a jól ismert portok: 0-1023 és a dinamikus vagy privát portok: 1024-65535.
A getservbyport() függvény visszaadja annak a szolgáltatásnak/alkalmazásnak a nevét, amelyet
azonosít a portszám. Irassuk ki, hogy melyik portszámhoz, milyen szolgáltatás/alkalmazás tartozik:
importsocketforport_numinrange(1024):
try:
service = socket.getservbyport(port_num)
print(port_num, service)
exceptOSError:
pass
Socket típusai:
Alapvetően kettő olyan socket típus van, amelyet szoktak használni (SOCK_STREAM,
SOCK_DGRAM), de több típusa is van (SOCK_RAW, SOCK_RDM, SOCK_SEQPACKET, SOCK_DCCPSOCK_PACKET), de
ezeket nagyon ritkán
szokás csak használni. Sőt ezek akár operációs rendszer
függőek is lehetnek. Az egyik típus, amivel foglalkozni fogunk, azok a
Stream Socketek (SOCK_STREAM), a másik a Datagram Socketek (SOCK_DGRAM). A Datagram Socketeket
gyakran hívják kapcsolat nélküli
socketeknek, mivel nem szükséges a kapcsolat kiépülése küldés előtt. A Stream Socketek TCP
protokollt használnak, hogy biztosítsák a sorrendhelyességet, a duplikátumok elkerülését és azt,
hogy a csomagok garantáltan megérkezzenek. Míg a Datagram Socketek UDP protokollt használnak,
ezáltal nincs garantálva a sorrendhelyesség, nem biztos, hogy megérkeznek a csomagok és duplikátumok
is lehetnek. TCP-t használnak fájl letöltésnél és web böngészésnél is. Például a HTTP protokoll is
TCP felett van megvalósítva. Ezzel szemben UDP-t használnak a videók, hangok küldésénél, ezért is
lehet az, hogy hirtelen megáll, felgyorul vagy leromlik a képminősége a videónak, amit éppen nézünk.
TCP socket:
Ahhoz, hogy tudjunk kommunikálni két végpont között, létre kell hoznunk a
kliensnél
és a szervernél is egy-egy socketet, ugyanazon paraméterekkel.
A socket első paramétere az address family, ami lehet AF_INET, ami azt jelenti,
hogy IPv4 címekket fog használni, AF_INET6, ami azt jelenti, hogy IPv6 címekket fog használni. Más
dolgok is lehetnek itt, de ezekkel most nem foglalkozunk. A második paramétere a socket típusa,
amelyekről már korábban esett szó.
Ha megnézzük az ábrát, azt láthatjuk az alsó részén, hogy a csomag mely részéhez
férünk hozzá adott address family és socket type beállítások mellett. A zöld szín azt jelenti, hogy
nekünk kell beállítanunk a csomag ezen részét. A kék azt jelenti, hogy a socket állítja be ezt a
részt és nekünk
nem kell ezzel foglalkoznunk. Ha megnézzük a fentebbi kódot, ahol Stream Socketet hoztunk létre,
akkor láthatjuk, hogy nekünk csak a
csomag üzenet részével kell foglalkoznunk, mert a socket mindent be fog állítani helyettünk.
A fenti ábrán azt láthatjuk, hogy a TCP-nél hogyan is működik a kommunikáció.
Először létre kell hozni mindkét félnél a socketet, hogy tudjanak majd kommunikálni ezen keresztül.
Ezt fentebb már láthattuk, hogy hogyan kell. Ezek után a szerveren a socketet hozzá kell bind-oljuk
egy porthoz, amelyen keresztül tudja az operációs rendszer, hogy a mi alkalmazásunknak szólnak a
csomagok. Fontos, hogy a bind mindig tuple-t vár el, ami úgy néz ki, hogy az első paramétere egy IP
cím vagy host név, második paramétere egy port.
addr = ("127.0.0.1", 8000)
sock.bind(addr)
Ezek után a szerveren el kell kezdenünk figyelni a csatlakozni akaró kliensekre.
Ezt a listen() függvénnyel tudjuk megtenni. A paraméterében egy számot kell megadnunk, ami azt
jelenti, hogy hány kapcsolat várakozhat, amikor a szerver elfoglalt, mielőtt visszautasítja a
kapcsolatot.
sock.listen(0)
print(f"Server is listening on {addr[0]}:{addr[0]}")
Most pedig a szerveren jön egy kicsit trükkös rész. Amikor elfogadjuk accept()
függvénnyel a kapcsolatot, létrejön egy új socket, amellyel tudunk küldeni és fogadni adatokat a
klienstől. Ez egy másik socket, mint amit eddig létrehoztunk. A korábban létrehozott socket arra
kell, hogy az új kapcsolatokat fogadni tudjuk.
connection_sock, client_address = sock.accept()
withconnection_sock:
print(f"Connection from {client_address}")
Most pedig jöhet a küldés és a fogadás. Itt mi döntjük el, hogy a szerver és a
kliens milyen sorrendben küld és fogad adatot. Lehet, hogy a szerver csak foad, de lehet, hogy
felváltva küldenek stb. Teljes mértékben ránk van bízva.
data = connection_sock.recv(16).decode()
print(f"Data: {data}")
Végezetül pedig le kell zárnunk a socketet, feltéve hogy nem with-et használtunk.
Fontos megjegyezni, hogy Stream Socket esetében, amikor valaki bezárja a socketet egy utolsó üzenet
elküldésre kerül, aminek nincs tartalma. Gyakorlatilag egy üres string. Ez egy jelzés a másik
félnek, hogy részünkről véget ért a kommunikáció.
sock.close()
Most térjünk át a kliens oldalra. Itt egyszerűen csak csatlakoznunk kell a
szerverre az IP címe és a port száma alapján.
addr = ("127.0.0.1", 8000)
sock.connect(addr)
Ezek után pedig küldhetünk is adatokat a szervernek vagy akár fogadhatunk is.
Küldés során használhatjuk a struct-ot vagy ha csak simán string-et küldünk elég az encode() is.
Nem megfelelő üzenet érkezett! Hiba van a szerveren, emiatt 0 byte-os csomag érkezett!
Teljes kód egy TCP szerverre:
(Windows-nál a végtelen ciklusban futó szervert sima „Ctrl+C”-vel nem tudjuk
kilőni parancssorban, hanem „Ctrl+Break” billentyűkombinációval lehet. A „Break” billentyű helye
laptoponként eltérhet: pl. Ctrl+Fn+Pause, Ctrl+Fn+B stb.)
importsocketwithsocket.socket(socket.AF_INET, socket.SOCK_STREAM) assock:
addr = ("127.0.0.1", 8000)
sock.bind(addr)
sock.listen(1)
print(f"Server is listening on {addr[0]}:{addr[1]}")
connection_sock, client_address = sock.accept()
withconnection_sock:
print(f"Connection from {client_address}")
data = connection_sock.recv(16).decode()
print(f"Data: {data}")
Amikor létrehoztuk a szerverünket láthattuk, hogy az accept() műveletnél megakadt
a kódunk futása egészen addig, amíg nem érkezett egy klienstől kapcsolat. Ez azért van, mert vannak
úgynevezett blokkoló műveletek. Ilyenek például az accept(), recv(), send(), connect() stb. Ezek
megállítják a programunk futását. Ennek elkerülése végett be tudjuk állítani a socketet olyan módba,
hogy ezen műveletei ne blokkolják a program futását.
sock.setblocking(0) # or sock.setblocking(False) or sock.settimeout(0.0) or sock.settimeout(1.0)sock.setblocking(1) # or sock.setblocking(True) or sock.settimeout(None)
Más dolgokat is be lehet állítani a socketen. Ezt a következő paranccsal lehet
megtenni: sock.setsockopt(level, optname, value). Az általunk használt level értékek az
alábbiak lesznek: socket.IPPROTO_IP, ami azt jelzi, hogy IP szintű beállításról van szó,
socket.SOL_SOCKET, ami azt jelzi, hogy socket API szintű beállításról van szó. Az optname a
beállítandó paraméter neve, például socket.SO_REUSEADDR, ami azt jelenti, hogy a kapcsolat bontása
után a portot hasznosítsa újra. A value lehet sztring vagy egész szám. Az előbbi esetén biztosítani
kell a hívónak, hogy a megfelelő biteket tartalmazza (struct segítségével). A socket.SO_REUSEADDR
esetén ha 0, akkor lesz hamis a „tulajdonság”, egyébként igaz.
Select segítségével több nyitott fájlon, pipe-on vagy socketen egyszerre is
tudunk figyelni. Visszatér, amint egy valamelyik írható, olvasható, vagy error történt. Miért is jó
ez nekünk? Select segítségével egyszerre több kapcsolatot is kezelni tudunk. Így akár egyszerre több
klienst is ki tud szolgálni egy szerver egyidejűleg.
A select() blokkoló művelet, addig vár, amíg legalább 1 socket készen nem áll a
feldolgozásra vagy le nem jár az idő (timeout).
A select() három listával tér vissza. Az elsőben azok a socketek vannak,
amelyeknél van bejövő adat, amely olvasható. A második azokat a socketeket tartalmazzák, amelyeknek
van szabad helyük adatok írására. A harmadikban azok vannak, amelyeknél error történt.
importselect...inputs = [server] # sockets from which we expect to readoutputs = [] # sockets to which we expect to writetimeout = 1# Wait for at least one of the sockets to be ready for processingreadable, writable, exceptional = select.select(inputs, outputs, inputs, timeout)
forsinreadable:
ifsisserver: # new client connect...else:
... # handle client
Teljes kód egy TCP számológép szerverre:
importsocketimportselectimportstructpacker = struct.Struct('iic')
server_addr = ('127.0.0.1', 8000)
withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asserver:
server.bind(server_addr)
server.listen(1)
print("Listening for new connections...")
inputs = [server]
timeout = 1try:
whileTrue:
readable, writeable, exceptional = select.select(inputs, inputs, inputs, timeout)
ifnot (readableorwriteableorexceptional):
continueforsockinreadable:
ifsockisserver: # new connectionclient, client_addr = sock.accept()
inputs.append(client)
print("Connected: ", client_addr)
else: # an existing connection is readabledata = sock.recv(packer.size)
ifnotdata:
print("Logout: ", sock)
inputs.remove(sock)
sock.close()
else:
a, b, c = packer.unpack(data)
res = eval(str(a) + c.decode() + str(b))
sock.send(str(res).encode())
except KeyboardInterrupt:
print("Closing the server...")
Teljes kód egy TCP számológép kliensére:
importsocketimportstructserver_addr = ('127.0.0.1', 8000)
packer = struct.Struct('iic')
withsocket.socket(socket.AF_INET, socket.SOCK_STREAM) asclient:
try:
a = input("Give me a number: ")
b = input("Give me an operator: ")
c = input("Give me a number: ")
iflen(b) > 1:
print("Operator should be only 1 character!")
exit(1)
ifbnotin"+-*/%":
print(f"This is not an operator: {b}")
exit(1)
packed_data = packer.pack(int(a), int(c), b.encode())
client.connect(server_addr)
client.send(packed_data)
data = client.recv(200)
print("Result: ", data.decode())
except ValueError:
print("Please use numbers!")
except KeyboardInterrupt:
print("\nInterrupted")
UDP socket:
Az UDP-vel a kapcsolat kiépítése nélkül tudunk kommunikálni. Amire szükségünk
lesz itt is, hogy létrehozzuk a socketet, amin keresztül kommunikálni fogunk, továbbá hozzá kell
bind-olni a porthoz a socketet. Fontos kiemelni, hogy most Datagram típusú socketet hozunk létre
(SOCK_DGRAM).
Nem lesz szükségünk listen(), accept() és connect() metódusokra. Ami különbség még, hogy nem recv()
és send() függvényeket használunk, hanem recvfrom() és sendto() függvényeket. Ez azért van, mert az
UDP-nél nem épül ki kapcsolat, így küldésnél meg kell adni paraméterben, hogy kinek küldjük, fogadó
oldalon pedig meg kell szerezzük azt az információt, hogy kinek kell esetleg választ küldeni.
Érdemes megjegyezni, hogy a TCP-nél egy üres stringet küld, mikor bontja a kapcsolatot. UDP-nél
nincs ilyen, ezért ezt mi magunknak kell megcsináljük, hogy miután elküldtünk és fogadtunk minden
adatot, a végén érdemes egy üres stringet még elküldeni, jelezve a szervernek, hogy nem kívánunk
tovább kommunikálni vele.
Egyszerű UDP szerver:
importsocketserver_addr = ('127.0.0.1', 8000)
withsocket.socket(socket.AF_INET, socket.SOCK_DGRAM) asserver:
server.bind(server_addr)
print("Waiting for any data...")
data, addr = server.recvfrom(1024)
print("Data:", data.decode())
print("Address:", addr)
Az alábbi kód egy web proxy. Miután elindítottuk a proxy szerver, írjuk be a
böngészőbe, hogy "localhost:9000". Ennek hatására a böngésző küld egy HTTP GET request-et, amelyet a
proxy szerverünk átír. Gyakorlatilag kicseréli a célt és az elérési utat egy másik weboldal elérési
címére. Ezáltal a GET request eljut az adott web szerverhez, aki visszaküldi a megfelelő HTML, CSS,
JS fájlokat, amelyeket a proxy szerverünk visszajuttat a böngészőnek.
Egy Ethernet keret 0-ákból és 1-esekből épül fel. Ezeket fel tudjuk írni egész
együtthatós polinomként. Például 1010 -> 1 * x^3 + 0 * x^2 + 1 * x^1 + 0 * x^0 = x^3 + x. Ezen felül
lesz egy generátor polinom, amit a protokoll határoz meg és mindkét félnek ismernie kell.
Csúsztassuk arrébb a keretet, annyival ahanyadik fokú a generátor polinom. Ezek után, ha
leosztjuk a keretet a generátor polinommal, akkor megkapjuk a maradék polinomot, amelyet a keret
végére kell rakni, ezért csúsztatuk arrébb. Ha például negyedfokú a generátor polinom,
akkor 4 biten ábrázolható a maradék polinom. Mivel hozzáraktuk a maradékot a kerethez, így biztosan
osztható lesz a generátor polinommal.
Fogadó oldalon, ha leosztjuk a generátor polinommal, akkor a többszörösét kell
kapjuk, azaz a maradéknak 0-nak kell lennie. Ha nem 0, akkor megsérült a keret. Még egy olyan eset
is előfordulhat, hogy olyan mértékben sérült a keret, hogy pont 0 lesz a maradék.
importbinascii, zlibtest_string = "Fill this with something".encode('utf-8')
print(hex(binascii.crc32(bytearray(test_string))))
print(hex(zlib.crc32(test_string)))
MD5:
Az MD5 ellenőrző összegként használható az fájlok/adatok integritásának
ellenőrzésére a nem szándékos sérülés ellen. Valójában nincs mód az adatok sértetlenségének
ellenőrzésére a számítógépen lévő hash kiszámítása nélkül. Erre lehet jó az MD5 vagy az SHA1.
importhashlibtest_string = "Fill this with something".encode('utf-8')
m = hashlib.md5()
m.update(test_string)
print(m.hexdigest())
SHA1:
importhashlibtest_string = "Fill this with something".encode('utf-8')
m = hashlib.sha1()
m.update(test_string)
print(m.hexdigest())
Kiegészítő anyag:
Raw socket:
A socket létrehozásától függően lehetőségünk van akár a csomag fejléceit is
beállítani. Windowson a Raw Socket limitált, mivel nincs AF_PACKET. Ezért legfeljebb az IP fejlécig
van lehetőségünk módosítani a csomagot, az Ethernet keret fejlécéhez nincs hozzáférésünk. Viszont
Unix rendszereken a teljes csomagot felépíthetjük, akár bájt szinten.
Az alábbi kód egy példa, hogy hogyan kell összerakni az Ethernet keretet, Vlan
fejlécet. Ez a kód csak Unix rendszereken működik!
importsocketimportstructinterface = "lo"# Create a raw socketsock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW)
sock.bind((interface, 0))
# Convert MAC addresses to binary formatsrc_mac = "00:0a:95:9d:68:16"dst_mac = "ff:ff:ff:ff:ff:ff"src_mac = bytes.fromhex(src_mac.replace(':', ''))
dst_mac = bytes.fromhex(dst_mac.replace(':', ''))
# Construct the Ethernet headereth_header = struct.pack('!6s6sH', dst_mac, src_mac, 0x8100)
# Construct the VLAN headervlan_id = 100tci = (0 << 13) | (0 << 12) | vlan_idvlan_header = struct.pack('!HH', tci, 0x0800)
# Construct the payloadpayload = b'Hello'# Combine the headers and payload into the complete frameframe = eth_header + vlan_header + payload# Send the framesock.send(frame)
print(f"Sent Ethernet frame with VLAN ID {vlan_id} on interface {interface}")
Feladatok
Az alábbi feladatok a gyakorlatokon elvégzendő kötelező, illetve
gyakorló
feladatok.
1. feladat:
Írassuk ki a 1..100-ig a portokat és a hozzájuk tartozó
protokollokat!
2. feladat:
Készítsünk egy egyszerű kliens-server alkalmazást, ahol a kliens
elküld egy ‚Hello server’ üzenetet, és a szerver pedig válaszol neki egy ‚Hello
kliens’ üzenettel!
3. feladat:
Változtassuk meg a 2. feladatot, hogy ne az előre megadott portot
adjuk, hanem egy tetszőlegeset kapjunk az oprendszertől! (sys.argv[1])
4. feladat:
Készítsünk egy szerver-kliens alkalmazást, ahol a kliens elküld 2
számot és egy operátort a szervernek, amely kiszámolja és
visszaküldi az eredményt. A kliens üzenete legyen struktúra.
5. feladat:
Csináljunk egy date servert! A kliens elküldi hogy 'getDate' és a
szerver visszaküldi az aktuális dátumot. Pl: datetime.now().isoformat()
6. feladat:
Csináljunk egy Nagybetűző szervert! A kliens elküld egy szöveget a
szerver visszaküldi nagybetűzve, pl: "as".upper() → "AS".
7. feladat:
Csináljunk egy dobóocka szervert! A kliens csatlakozik és elküldi
hogy hány oldalú
dobókockára kér egy dobást. Pl: "20" → random.randint(1,20)
8. feladat:
Készítsünk egy TCP alkalmazást, amelyen több
kliens képes egyszerre üzenetet küldeni a
szervernek, amely minden üzenetre csak
annyit ír vissza, hogy „OK”. (Használjuk a select
függvényt!)
9. feladat:
Alakítsuk át úgy a számológép szervert, hogy
egyszerre több klienssel is képes legyen
kommunikálni! Ezt a select függvény segítségével
tegye! Alakítsuk át a kliens működését úgy, hogy ne csak
egy kérést küldjön a szervernek, hanem
csatlakozás után 5 kérés-válasz üzenetváltás
történjen, minden kérés előtt 2 mp várakozással
(time.sleep(2))! A kapcsolatot csak a legvégén
bontsa a kliens!
10. feladat:
Készítsünk egy kliens-szerver alkalmazást, amely UDP
protokollt használ. A kliens küldje a ‚Hello Server’ üzenetet a
szervernek, amely válaszolja a ‚Hello Kliens’ üzenetet.
11. feladat:
Készítsünk egy szerver-kliens alkalmazást, ahol a kliens elküld
2 számot és egy operátort a szervernek, amely kiszámolja és
visszaküldi az eredményt. A kliens üzenete legyen struktúra.
12. feladat:
Küldjünk át egy képet UDP segítségével.
200 byte-onként küldjünk
Ha vége a filenak akkor küldjünk üres stringet
Minden kapott üzenetre OK legyen a válasz
13. feladat:
Készítsünk egy proxy szervert
TCP-s számológép klienstől kapja az üzenetet. (korábbi gyakorlat)
UDP-s számológép szervernek küldi tovább
A szerver visszaküldi a proxynak, aki visszaküldi a kliensnek
14. feladat:
Egy számológép kliens az UDP szervertől kérje
el a TCP-s szerver elérhetőségét!
Küldjön egy ‚GET’ üzenetet. A kliens küldjön egy ‚Hello Server’ üzenetet a
UDP szervernek, aki visszaküldi a TCP szerver
elérését, ahova a számokat és az operátort
fogja elküldeni. A TCP szerver legyen a korábbi számológép
szerver.
15. feladat:
Készítsünk proxyt, ahol a kliens egy webböngésző, a szerver
pedig egy webszerver.
A proxy tovvábítsa a böngésző kérését a szervernek.
Pl: python netProxy.py ggombos.web.elte.hu 9000.
Böngészőben: localhost 9000
16. feladat:
Adva a 𝐺 𝑥 = 𝑥^4 + 𝑥^3 + 𝑥 + 1 generátor
polinom. Számoljuk ki a
1100 1010 1110 1100 bemenethez a 4-bit CRC
ellenőrzőösszeget! A fenti üzenet az átvitel során sérül, a vevő
adatkapcsolati rétege az
1100 1010 1101 1010 0100 bitsorozatot kapja.
Történt-e olyan hiba az átvitel során, amit a
generátor polinommal fel lehet ismerni? Ha nem,
akkor ennek mi lehet az oka?
17. feladat:
Készítsünk a számológéphez egy proxy-t, ami a klienstől
kapott TCP kéréseket az UDP serverhez küldi, majd az
eredmény a proxyn keresztül vissza a kliensnek.
18. feladat:
Készítsünk egy egyszerű TCP alapú proxyt (átjátszó). A proxy a
kliensek felé
szerverként látszik, azaz a kliensek csatlakozhatnak hozzá. A proxy a
csatlakozás után kapcsolatot nyit egy szerver felé (parancssori argumentum),
majd minden a klienstől jövő kérést továbbítja a szerver felé és a szervertől
jövő válaszokat pedig a kliens felé.
Pl: ./proxy.py 80 ik.elte.hu 80.
Web browserbe írjuk be: localhost.
megj: nincs close request!
19. feladat:
A http://ik.elte.hu/hallgato szervezet alatti oldalak ne legyenek
elérhetők a proxyn keresztül.
A válasz legyen valamilyen egyszerű HTML üzenet, ami jelzi a
blokkolást.
megj: header: „HTTP/1.1 404 Not Found\n\n”