First implementation of transparent client tunnel using SAM

The transparent proxy creates a client tunnel to the requested destination for each client connection.
This is untested for now, but a server tunnel is also incoming if this works well.

#4 - Investigate extending pr0xy to use SAM
This commit is contained in:
2020-12-13 00:07:34 +01:00
parent 4dc8f22c29
commit 3ea2f2a453
9 changed files with 469 additions and 283 deletions

View File

@@ -36,8 +36,9 @@ echo "nameserver 127.0.0.1" > /etc/resolv.conf
ulogd -d
tcpdump -i any -w /mount/tcp.dmp &
python3 /opt/bin/fake-dns.py -s "/tmp/fake-dns" &
python3 /opt/pr0cks/pr0cks.py \
--port ${LOCAL_PROXY_PORT}
--proxy "http:${I2PD_IP}:${I2PD_PORT}" \
export PYTHONPATH=/opt/bin
python3 /opt/bin/trans_proxy/cli.py \
--port ${LOCAL_PROXY_PORT} \
--sam-host "${I2PD_IP}" \
--sam-port "${I2PD_PORT}" \
--verbose

View File

@@ -1,279 +0,0 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import argparse
import logging
import os
import re
import signal
import socketserver
import threading
from datetime import datetime
from ipaddress import ip_address, IPv4Address, AddressValueError
from pathlib import Path
from threading import RLock
from time import sleep
from dnslib import dns
from dnslib.server import DNSServer, BaseResolver
from faker import Faker
SERIAL_NO = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds())
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S'))
logger = logging.getLogger(__name__)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def validate_fqdn(domain_name):
"""
Taken from https://stackoverflow.com/questions/2532053/validate-a-hostname-string/33715765#33715765
:param domain_name:
:type domain_name: basestring
:rtype: bool
"""
if domain_name.endswith('.'):
domain_name = domain_name[:-1]
if len(domain_name) < 1 or len(domain_name) > 253:
return False
ldh_re = re.compile('^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$',
re.IGNORECASE)
return all(ldh_re.match(x) for x in domain_name.split('.'))
class UnixSocketServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
"""
A simple, threaded server that follows the DNSServer signatures
"""
def __init__(self, socket_path, request_handler_class, bind_and_activate=True):
self._socket_path = socket_path
# Remove old socket if it's not being used
self._rm_socket()
super().__init__(socket_path, request_handler_class, bind_and_activate)
self.thread = threading.Thread(target=self.serve_forever)
self.thread.daemon = True
def start_thread(self):
self.thread.start()
def stop(self):
self.shutdown()
self.server_close()
# Attempt to remove socket before exiting
self._rm_socket(True)
def _rm_socket(self, log_error=False):
try:
os.unlink(self._socket_path)
except Exception as e:
if log_error:
logging.getLogger("UnixSocketServer").warn(
"Couldn't remove unix socket: %s", e
)
def isAlive(self):
return self.thread.isAlive()
class UnixSocketHandler(socketserver.BaseRequestHandler):
def handle(self):
host = ""
try:
# An ip address string is max 16 byes
ip_str = self.request.recv(256).strip().decode()
ip_address(ip_str)
host = RandomResolver.CACHE_IP[ip_str]
except AddressValueError:
pass
except Exception as e:
logging.getLogger("UnixSocketHandler") \
.warn("Couldn't handle unix data: %s", e)
try:
self.request.sendall(bytes(host.encode()))
except Exception as e:
logging.getLogger("UnixSocketHandler") \
.warn("Couldn't respond: %s", e)
class RandomResolver(BaseResolver):
"""
Resolves a random, public IP for every unique DNS request.
"""
CACHE_IP = {}
CACHE_FQDN = {}
_FAKER = Faker(providers=["faker.providers.internet"])
_FAKER_LOCK = RLock()
def __init__(self, resolve_dir):
self.resolve_dir = Path(resolve_dir)
@classmethod
def load_dir(cls, resolve_dir):
# TODO: implement this
raise NotImplementedError()
@staticmethod
def ip2path(address, resolve_dir):
"""
Constructs the path to get stored FQDN of the given IP address
:param address: IPv4 address
:type address: basestring
:param resolve_dir: The root dir where the IPs and FQDNs will be stored
:type resolve_dir: Path
:rtype: Path | None
"""
try:
if not isinstance(ip_address(address), IPv4Address):
return
except ValueError:
return
a, b, c, d = address.split(".")
return resolve_dir / a / b / c / d
@classmethod
def reload_ip(cls, ip, resolve_dir):
"""
Retrieves the stored FQDN from the given IP
:param ip: Should be a valid IPv4 address
:type ip: basestring
:param resolve_dir:
:type resolve_dir:
:return:
:rtype:
"""
ip_path = cls.ip2path(ip, resolve_dir)
if not (ip_path and ip_path.is_file()):
return
fqdn = ip_path.read_text().strip()
if not validate_fqdn(fqdn):
return
# Make sure to remove old entries
for _ip, _fqdn in cls.CACHE_IP.items():
if _fqdn == fqdn:
del cls.CACHE_FQDN[_fqdn]
break
cls.CACHE_IP[ip] = fqdn
cls.CACHE_FQDN[fqdn] = ip
@classmethod
def create_fake_entry(cls, fqdn):
ip = cls._FAKER.ipv4_public(network=False, address_class=None)
cls.CACHE_FQDN[fqdn] = ip
cls.CACHE_IP[ip] = fqdn
logging.getLogger("RandomResolver").info(
"New IP for %s: %s",
fqdn, ip
)
return ip
@classmethod
def get_ip(cls, fqdn):
fqdn = fqdn.strip('.')
with cls._FAKER_LOCK:
ip = cls.CACHE_FQDN.get(fqdn)
if not ip:
ip = cls.create_fake_entry(fqdn)
return ip
def resolve(self, request, handler):
"""
Resolves a random, public IP for each unique DNS request
:type request: dnslib.dns.DNSRecord
:type handler: dnslib.server.DNSHandler
:rtype: dnslib.dns.DNSRecord
"""
reply = request.reply()
fqdn = str(request.q.get_qname())
reply.add_answer(dns.RR(
fqdn, rdata=dns.A(self.get_ip(fqdn))
))
return reply
def main(args):
"""
:param args:
:type args: argparse.Namespace
"""
log = logging.getLogger("fake-dns.main")
port = args.port
resolve_dir = Path(args.resolve_dir)
resolver = RandomResolver(resolve_dir)
udp_server = DNSServer(resolver, port=port)
tcp_server = DNSServer(resolver, port=port, tcp=True)
servers = [udp_server, tcp_server]
if args.socket_path:
socket_path = Path(args.socket_path)
socket_path.parent.mkdir(parents=True, exist_ok=True)
log.info("Creating unix socket to %s", socket_path)
servers.append(UnixSocketServer(str(socket_path), UnixSocketHandler))
def stop_servers():
for _server in servers:
_server.stop()
def handle_sig(signum, frame):
logger.info('pid=%d, got signal: %s, stopping...', os.getpid(), signal.Signals(signum).name)
stop_servers()
exit(0)
signal.signal(signal.SIGTERM | signal.SIGINT, handle_sig)
logger.info('starting DNS server on port %d', port)
for server in servers:
server.start_thread()
try:
while udp_server.isAlive():
sleep(1)
except KeyboardInterrupt:
pass
finally:
stop_servers()
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
parser = argparse.ArgumentParser(
description="A DNS server that returns fake IPs for requests"
)
parser.add_argument("-p", "--port",
help="Which port to use to accept DNS requests",
type=int,
default=53)
parser.add_argument("-r", "--resolve-dir",
help="Where the resolves will stored",
default="/etc/resolve/")
parser.add_argument("-s", "--socket-path",
help="A path to create a socket where"
" reverse requests can be mad")
main(parser.parse_args())

View File

@@ -0,0 +1,15 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

View File

@@ -0,0 +1,128 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import argparse
import asyncio
import logging
import multiprocessing
import os
import typing
from time import sleep
from i2plib import sam
from trans_proxy import fake_dns
from trans_proxy.process import AsyncProcess
from trans_proxy.servers import ClientTcpTunnel
ENV_SAM_HOST = "I2P_SAM_HOST"
ENV_SAM_PORT = "I2P_SAM_PORT"
ENV_DNS_PORT = "I2P_DNS_PORT"
logger = logging.getLogger("trans_proxy")
def main():
parser = argparse.ArgumentParser(
description="Forwards packets to an I2P instance and handles DNS requests"
)
parser.add_argument(
"--verbose",
action='store_true',
help="Activates verbose logs")
parser.add_argument(
"-p", "--port",
default=1234,
help="Where all traffic should enter to be forwarded")
parser.add_argument(
"--sam-host",
default=os.environ.get(ENV_SAM_HOST, sam.DEFAULT_ADDRESS[0]))
parser.add_argument(
"--sam-port",
default=os.environ.get(ENV_SAM_PORT, sam.DEFAULT_ADDRESS[1]))
dns_group = parser.add_argument_group('dns')
dns_group.add_argument(
"--dns-port",
default=os.environ.get(ENV_DNS_PORT, 1053))
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
with multiprocessing.Manager() as m:
ip_dict = m.dict()
processes = [
AsyncProcess(
target=start_client_tcp_tunnel,
kwargs={
"sam_host": args.sam_host,
"sam_port": args.sam_port,
"ip_dict": ip_dict,
}
),
multiprocessing.Process(
target=fake_dns.main,
kwargs={
"port": args.dns_port,
}
)
]
exec_processes(processes)
def exec_processes(processes: typing.List[multiprocessing.Process]):
"""
Starts processes and waits for them to end
"""
# TODO: manage processes better
for process in processes:
process.start()
try:
all_processes_done = False
while not all_processes_done:
sleep(1)
all_processes_done = all(
not process.is_alive()
for process in processes
)
except KeyboardInterrupt:
pass
finally:
for process in processes:
if process.is_alive():
process.close()
async def start_client_tcp_tunnel(
sam_host,
sam_port,
ip_dict,
host="127.0.0.1", port=1234,
**kwargs
):
loop = asyncio.get_running_loop()
server = await loop.create_server(lambda: ClientTcpTunnel(
sam_host=sam_host,
sam_port=sam_port,
ip_dict=ip_dict,
), host=host, port=port)
await server.serve_forever()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,156 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import argparse
import logging
import os
import re
import signal
from datetime import datetime
from pathlib import Path
from threading import RLock
from time import sleep
from dnslib import dns
from dnslib.server import DNSServer, BaseResolver
from faker import Faker
from trans_proxy.utils import ReverseDict
SERIAL_NO = int((datetime.utcnow() - datetime(1970, 1, 1)).total_seconds())
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s', datefmt='%H:%M:%S'))
logger = logging.getLogger("fake_dns")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
def validate_fqdn(domain_name):
"""
Taken from https://stackoverflow.com/questions/2532053/validate-a-hostname-string/33715765#33715765
:param domain_name:
:type domain_name: basestring
:rtype: bool
"""
if domain_name.endswith('.'):
domain_name = domain_name[:-1]
if len(domain_name) < 1 or len(domain_name) > 253:
return False
ldh_re = re.compile('^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$',
re.IGNORECASE)
return all(ldh_re.match(x) for x in domain_name.split('.'))
class RandomResolver(BaseResolver):
"""
Resolves a random, public IP for every unique DNS request.
"""
_FAKER = Faker(providers=["faker.providers.internet"])
_FAKER_LOCK = RLock()
def __init__(self, ip_dict=None):
if dict is None:
self.fqdn_dict = {}
else:
self.fqdn_dict = ReverseDict(ip_dict)
def create_fake_entry(self, fqdn):
ip = self._FAKER.ipv4_public(network=False, address_class=None)
self.fqdn_dict[fqdn] = ip
logging.getLogger("RandomResolver").info(
"New IP for %s: %s",
fqdn, ip
)
return ip
def get_ip(self, fqdn):
fqdn = fqdn.strip('.')
with self._FAKER_LOCK:
ip = self.fqdn_dict.get(fqdn)
if not ip:
ip = self.create_fake_entry(fqdn)
return ip
def resolve(self, request, handler):
"""
Resolves a random, public IP for each unique DNS request
:type request: dnslib.dns.DNSRecord
:type handler: dnslib.server.DNSHandler
:rtype: dnslib.dns.DNSRecord
"""
reply = request.reply()
fqdn = str(request.q.get_qname())
reply.add_answer(dns.RR(
fqdn, rdata=dns.A(self.get_ip(fqdn))
))
return reply
def main(port: int = 53, ip_dict=None):
"""
Creates a UDP and TCP DNS server with a FakeResolver
:param port: On localhost to access DNS requests
:param ip_dict: A dict into which IP addresses of resolved domains will be stored
"""
ip_dict = ip_dict or {}
resolver = RandomResolver(ip_dict)
udp_server = DNSServer(resolver, port=port)
tcp_server = DNSServer(resolver, port=port, tcp=True)
servers = [udp_server, tcp_server]
def stop_servers():
for _server in servers:
_server.stop()
def handle_sig(signum, frame):
logger.info('pid=%d, got signal: %s, stopping...', os.getpid(), signal.Signals(signum).name)
stop_servers()
exit(0)
signal.signal(signal.SIGTERM | signal.SIGINT, handle_sig)
logger.info('starting DNS server on port %d', port)
for server in servers:
server.start_thread()
try:
while udp_server.isAlive():
sleep(1)
except KeyboardInterrupt:
pass
finally:
stop_servers()
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
parser = argparse.ArgumentParser(
description="A DNS server that returns fake IPs for requests"
)
parser.add_argument("-p", "--port",
help="Which port to use to accept DNS requests",
type=int,
default=53)
main(**parser.parse_args().__dict__)

View File

@@ -0,0 +1,30 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import multiprocessing
from inspect import isawaitable
class AsyncProcess(multiprocessing.Process):
"""
Adds the ability to run async targets in new processes
"""
def run(self) -> None:
if self._target:
possible_awaitable = self._target(*self._args, **self._kwargs)
if isawaitable(possible_awaitable):
asyncio.run(possible_awaitable)

View File

@@ -0,0 +1,80 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import typing
from asyncio import transports
from asyncio.streams import StreamReader, StreamWriter
import i2plib as i2plib
from trans_proxy.utils import get_original_ip
class ClientTcpTunnel(asyncio.Protocol):
"""
The transparent proxy that forwards clients to their I2P destinations
"""
def __init__(self, sam_host: str, sam_port: int, ip_dict: dict):
self.sam_port = sam_port
self.sam_host = sam_host
self.ip_dict = ip_dict
self.reader: typing.Optional[StreamReader] = None
self.writer: typing.Optional[StreamWriter] = None
self.transport: typing.Optional[transports.Transport] = None
# TODO generate a unique session name
self.session_name = "test-connect"
self._destination = None
@property
def destination(self) -> str:
"""
The I2P destination that this tunnel is pointing to
"""
if self._destination is None:
self._update_destination()
return self._destination
def _update_destination(self):
# TODO: handle non-existent destination
self._destination = self.ip_dict[
get_original_ip(self.transport.get_extra_info('socket'))
]
async def connection_made(self, transport: transports.Transport) -> None:
self.transport = transport
self._update_destination()
session_name = self.session_name
# create a SAM stream session
await i2plib.create_session(session_name, (self.sam_host, self.sam_port))
# connect to a destination
# TODO: Add destination_lookup to FakeResolver
self.reader, self.writer = await i2plib.stream_connect(session_name, self.destination)
async def data_received(self, data: bytes) -> None:
# write data to a socket
self.writer.write(data)
# asynchronously receive data
while i2p_response := await self.reader.read(4096):
self.transport.write(i2p_response)
def eof_received(self):
# close the connection
self.writer.close()

View File

@@ -0,0 +1,53 @@
# i2p-docker-proxy
# Copyright (C) 2019 LoveIsGrief
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import socket
import struct
SO_ORIGINAL_DST = 80
"""
A socket option set by netfilter when packets are redirected in the NAT table
"""
class ReverseDict(dict):
"""
A dict that stores the reverse of itself in another dict
"""
def __init__(self, reverse_dict: dict):
super().__init__()
self._reverse_dict = reverse_dict
def __setitem__(self, key, value):
super(ReverseDict, self).__setitem__(key, value)
self._reverse_dict[value] = key
def update(self, other=None, **kvps) -> None:
super(ReverseDict, self).update(other, **kvps)
self._reverse_dict.update({
v: k
for k, v in self.items()
})
def clear(self) -> None:
super(ReverseDict, self).clear()
self._reverse_dict.clear()
def get_original_ip(sock: socket.socket) -> str:
odestdata = sock.getsockopt(socket.SOL_IP, SO_ORIGINAL_DST, 16)
_, port, a1, a2, a3, a4 = struct.unpack("!HHBBBBxxxxxxxx", odestdata)
return "%d.%d.%d.%d" % (a1, a2, a3, a4)