Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple STUN server #720

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions stun/LICENSE

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions stun/debian/changelog
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ooni-stun (0.0.1) unstable; urgency=medium

* First release

-- Federico Ceratto <[email protected]> Mon, 11 Sep 2023 11:58:05 +0200
26 changes: 26 additions & 0 deletions stun/debian/control
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Source: ooni-stun
Section: python
Priority: optional
Maintainer: Federico Ceratto <[email protected]>
Build-Depends: debhelper-compat (= 12),
dh-python,
python3,
python3-setproctitle,
python3-setuptools,
python3-statsd,
python3-systemd
Standards-Version: 4.5.1

Package: ooni-stun
Architecture: all
Depends: ${misc:Depends},
${python3:Depends},
nftables,
python3-setuptools,
python3-statsd,
python3-systemd,
python3-ujson,
Suggests:
python3-pytest
Description: STUN server
STUN server
2 changes: 2 additions & 0 deletions stun/debian/copyright
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: ooni-stun
3 changes: 3 additions & 0 deletions stun/debian/install
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
debian/ooni-stun.service /lib/systemd/system/
debian/udp_3478.nft /etc/ooni/nftables/
ooni-stun.py /usr/bin/
32 changes: 32 additions & 0 deletions stun/debian/ooni-stun.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[Unit]
Description=ooni-stun
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/bin/ooni-stun.py
Restart=on-abort
Type=simple
RestartSec=2s

User=oonistun
Group=oonistun
ReadOnlyDirectories=/
ReadWriteDirectories=/proc/self

PermissionsStartOnly=true
LimitNOFILE=65536

# Sandboxing
CapabilityBoundingSet=CAP_SETUID CAP_SETGID
SystemCallFilter=~@clock @debug @cpu-emulation @keyring @module @mount @obsolete @raw-io @reboot @swap
NoNewPrivileges=yes
PrivateDevices=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=full
ProtectKernelModules=yes
ProtectKernelTunables=yes

[Install]
WantedBy=multi-user.target
25 changes: 25 additions & 0 deletions stun/debian/postinst
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/bin/sh

set -e

case "$1" in
configure)
addgroup --system --quiet oonistun
adduser --system --quiet --ingroup oonistun --home /var/lib/oonistun oonistun
;;

abort-upgrade|abort-remove|abort-deconfigure)
;;

*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac

# dh_installdeb will replace this with shell code automatically
# generated by other debhelper scripts.

#DEBHELPER#

exit 0
6 changes: 6 additions & 0 deletions stun/debian/rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/make -f
export DH_VERBOSE = 1
export PYBUILD_DISABLE=test

%:
dh $@ --with python3 --buildsystem=pybuild
1 change: 1 addition & 0 deletions stun/debian/source/format
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.0 (quilt)
2 changes: 2 additions & 0 deletions stun/debian/udp_3478.nft
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Deployed as /etc/ooni/nftables/udp_3478.nft by the ooni-stun deb package
add rule inet filter input udp dport 3478 counter accept comment "Incoming STUN"
177 changes: 177 additions & 0 deletions stun/ooni-stun.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python3

"""
OONI-specific STUN server. Implements a subset of STUN.
Based on https://github.com/martin-steghoefer/fakestun
Released under GPLv3
"""

import ipaddress
import logging
import socket
import time

from setproctitle import setproctitle # debdeps: python3-setproctitle
from systemd.journal import JournalHandler # debdeps: python3-systemd
import statsd # debdeps: python3-statsd


# IP address and UDP port to use for incoming requests
LISTEN_IPADDR = "0.0.0.0" # Required
LISTEN_PORT = 3478 # Required; normally 3478


# CHANGED-ADDRESS attribute of response
RESPONSE_CHANGED_IPADDR = "" # If empty return no attribute CHANGED-ADDRESS
RESPONSE_CHANGED_PORT = -1 # If negative return no attribute CHANGED-ADDRESS


log = logging.getLogger("ooni-stun")
log.addHandler(JournalHandler(SYSLOG_IDENTIFIER="ooni-stun"))
log.setLevel(logging.DEBUG)
metrics = statsd.StatsClient("127.0.0.1", 8125, prefix="ooni-stun")

rtt_lookup = {}


def uint16_to_bytes(number):
return number.to_bytes(2, byteorder="big", signed=False)


class Attribute:
"""STUN response attribute"""

def __init__(self, attributeType: int) -> None:
self.__attributeType = attributeType

def gen_bytes(self):
attributeValue = self.serialize()
return (
uint16_to_bytes(self.__attributeType)
+ uint16_to_bytes(len(attributeValue))
+ attributeValue
)


class IPv4Attr(Attribute):
"""STUN response attribute consisting of IPv4 address and Port (e.g. for MAPPED-ADDRESS)"""

MAPPED_ADDR = 1
SOURCE_ADDR = 4
CHANGED_ADDR = 5

def __init__(self, attributeType: int, ipaddr, port: int) -> None:
super().__init__(attributeType)
self.__ipaddr = ipaddr
self.__port = port

def serialize(self):
protocolIPv4 = uint16_to_bytes(1)
port = uint16_to_bytes(self.__port)
ipaddr = self.__ipaddr
return protocolIPv4 + port + ipaddr


class TextAttribute(Attribute):
TYPE_SERVER = 32802

def __init__(self, attributeType, text):
super().__init__(attributeType)
self.__text = text

def serialize(self):
return self.__text.encode("utf-8") + b"\x00"


class ResponseMessage:
"""Complete information for a STUN response message"""

"""Message type corresponding to the response to a Binding Request"""

BINDING_RESPONSE = 257

def __init__(self, messageType, transaction_id):
self.__messageType = messageType
self.__messageTransactionID = transaction_id
self.__attributes = []

def add_attribute(self, attribute: Attribute):
self.__attributes.append(attribute)

def gen_bytes(self):
attributesBinary = b"".join(
map(lambda attribute: attribute.gen_bytes(), self.__attributes)
)
return (
uint16_to_bytes(self.__messageType)
+ uint16_to_bytes(len(attributesBinary))
+ self.__messageTransactionID
+ attributesBinary
)


def pack_ipaddr(ipaddr: str) -> bytes:
return ipaddress.IPv4Address(ipaddr).packed


def log_rtt(transaction_id, sock_addr) -> None:
# Maintains the rtt_lookup dict
if transaction_id not in rtt_lookup:
rtt_lookup[transaction_id] = time.perf_counter_ns()

else:
t0 = rtt_lookup.pop(transaction_id)
elapsed = time.perf_counter_ns() - t0


@metrics.timer("handle_request")
def reply(req: bytes, req_ipaddr, req_port: int, sock) -> None:
log.debug("Received request")
if len(req) < 21 or req[0:2] != uint16_to_bytes(1):
log.debug("Unsupported request")
return

transaction_id = req[4:20]
sock_addr_ipa = ipaddress.IPv4Address(req_ipaddr)

# log_rtt(transaction_id, sock_addr_ipa)

resp = ResponseMessage(ResponseMessage.BINDING_RESPONSE, transaction_id)

# MAPPED-ADDRESS attribute
# (client or NAT ip address)
mapped_ipaddr = sock_addr_ipa.packed
resp.add_attribute(IPv4Attr(IPv4Attr.MAPPED_ADDR, mapped_ipaddr, req_port))

# SOURCE-ADDRESS attribute
src_ipaddr = ipaddress.IPv4Address(LISTEN_IPADDR).packed
resp.add_attribute(IPv4Attr(IPv4Attr.SOURCE_ADDR, src_ipaddr, LISTEN_PORT))

# custom SOURCE-ADDRESS attribute
# resp.add_attribute(IPv4Attr(IPv4Attr.SOURCE_ADDR, src_ipaddr, src_port))

# CHANGED-ADDRESS attribute
# resp.add_attribute(IPv4Attr(IPv4Attr.CHANGED_ADDR, changed_ipaddr,
# changed_port))

# SERVER attribute
# resp.add_attribute(TextAttribute(TextAttribute.TYPE_SERVER, "replaceme"))

payload = resp.gen_bytes()
sock.sendto(payload, (req_ipaddr, req_port))


def run():
setproctitle("ooni-stun")
log.info("Starting")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((LISTEN_IPADDR, LISTEN_PORT))
log.info("Started")
while True:
req, tup = sock.recvfrom(1024)
req_ipaddr, req_port = tup
reply(req, req_ipaddr, req_port, sock)


if __name__ == "__main__":
run()
Empty file added stun/setup.py
Empty file.
Loading