Ответ 1
Есть как минимум две проблемы с OnionProtocol
:
- Самый внутренний
TLSMemoryBIOProtocol
становитсяwrappedProtocol
, когда он должен быть самым внешним; -
ProtocolWithoutConnectionLost
не выталкивает любой стекTLSMemoryBIOProtocol
offOnionProtocol
, потому чтоconnectionLost
вызывается только после того, как методыFileDescriptor
doRead
илиdoWrite
возвращают причину отключения.
Мы не можем решить первую проблему, не изменив способ OnionProtocol
управлять своим стеком, и мы не можем решить вторую, пока не выясним новую реализацию стека. Неудивительно, что правильный дизайн является прямым следствием того, как потоки данных в Twisted, поэтому мы начнем с анализа потока данных.
Twisted представляет установленную связь с экземпляром twisted.internet.tcp.Server
или twisted.internet.tcp.Client
. Поскольку единственная интерактивность в нашей программе происходит в stoptls_client
, мы рассмотрим только поток данных в экземпляр Client
и из него.
Позвольте разогреться с минимальным клиентом LineReceiver
, который отбирает обратные линии, полученные от локального сервера на порте 9999:
from twisted.protocols import basic
from twisted.internet import defer, endpoints, protocol, task
class LineReceiver(basic.LineReceiver):
def lineReceived(self, line):
self.sendLine(line)
def main(reactor):
clientEndpoint = endpoints.clientFromString(
reactor, "tcp:localhost:9999")
connected = clientEndpoint.connect(
protocol.ClientFactory.forProtocol(LineReceiver))
def waitForever(_):
return defer.Deferred()
return connected.addCallback(waitForever)
task.react(main)
Как только установленное соединение установлено, Client
становится нашим протоколом LineReceiver
и поддерживает вход и выход:
Новые данные с сервера заставляют реактор вызывать метод Client
doRead
, который, в свою очередь, передает то, что он получил методу LineReceiver
dataReceived
. Наконец, LineReceiver.dataReceived
вызывает LineReceiver.lineReceived
, когда доступна хотя бы одна строка.
Наше приложение отправляет строку данных обратно на сервер, вызывая LineReceiver.sendLine
. Это вызывает write
в транспортной привязке к экземпляру протокола, который является тем же экземпляром Client
, который обрабатывал входящие данные. Client.write
упорядочивает данные, отправляемые реактором, а Client.doWrite
фактически отправляет данные через сокет.
Мы готовы посмотреть на поведение OnionClient
, которое никогда не вызывает startTLS
:
OnionClient
завернуты в OnionProtocol
s, которые являются сутью нашей попытки вложенных TLS. В качестве подкласса twisted.internet.policies.ProtocolWrapper
экземпляр OnionProtocol
является своего рода протокольно-транспортным сэндвичем; он представляет собой протокол для транспорта более низкого уровня и в качестве транспорта для протокола, который он переносит через маскарад, установленный во время соединения, WrappingFactory
.
Теперь Client.doRead
вызывает OnionProtocol.dataReceived
, который проксирует данные до OnionClient
. В качестве транспорта OnionClient
OnionProtocol.write
принимает строки для отправки из OnionClient.sendLine
и проксирует их до Client
, свой собственный транспорт. Это нормальное взаимодействие между ProtocolWrapper
, его завернутым протоколом и собственным транспортом, поэтому естественные потоки данных поступают к каждому из них без каких-либо проблем.
OnionProtocol.startTLS
делает что-то другое. Он пытается вставить новый ProtocolWrapper
- который является TLSMemoryBIOProtocol
- между установленной парой протокола и транспорта. Это кажется довольно простым: ProtocolWrapper
хранит протокол верхнего уровня в качестве атрибута wrappedProtocol
и прокси-серверы write
и другие атрибуты вплоть до собственного транспорта. startTLS
должен иметь возможность вводить новый TLSMemoryBIOProtocol
, который обертывает OnionClient
в соединение, исправляя этот экземпляр над его собственными wrappedProtocol
и transport
:
def startTLS(self):
...
connLost = ProtocolWithoutConnectionLost(self.wrappedProtocol)
connLost.onion = self
# Construct a new TLS layer, delivering events and application data to the
# wrapper just created.
tlsProtocol = TLSMemoryBIOProtocol(None, connLost, False)
# Push the previous transport and protocol onto the stack so they can be
# retrieved when this new TLS layer stops.
self._tlsStack.append((self.transport, self.wrappedProtocol))
...
# Make the new TLS layer the current protocol and transport.
self.wrappedProtocol = self.transport = tlsProtocol
Здесь поток данных после первого вызова startTLS
:
Как и ожидалось, новые данные, отправленные в OnionProtocol.dataReceived
, перенаправляются в TLSMemoryBIOProtocol
, хранящиеся в _tlsStack
, который передает расшифрованный открытый текст на OnionClient.dataReceived
. OnionClient.sendLine
также передает свои данные в TLSMemoryBIOProtocol.write
, который шифрует его и отправляет полученный зашифрованный текст в OnionProtocol.write
, а затем Client.write
.
К сожалению, эта схема завершилась неудачно после второго вызова startTLS
. Основная причина этой строки:
self.wrappedProtocol = self.transport = tlsProtocol
Каждый вызов startTLS
заменяет wrappedProtocol
на самый внутренний TLSMemoryBIOProtocol
, хотя данные, полученные с помощью Client.doRead
, были зашифрованы самым внешним:
Однако теги transport
s вложены правильно. OnionClient.sendLine
может вызывать только его транспорт write
- то есть OnionProtocol.write
- поэтому OnionProtocol
должен заменить свой transport
на самый внутренний TLSMemoryBIOProtocol
, чтобы гарантировать, что записи последовательно вложены внутри дополнительных уровней шифрования.
Таким образом, решение должно гарантировать, что данные будут проходить через первый TLSMemoryBIOProtocol
на _tlsStack
к следующему, в свою очередь, так что каждый уровень шифрования отслаивается в обратном порядке:
Представление _tlsStack
в качестве списка кажется менее естественным с учетом этого нового требования. К счастью, представление входящего потока данных линейно предполагает новую структуру данных:
Как ошибочный, так и правильный поток входящих данных напоминают односвязный список, причем wrappedProtocol
служит в качестве ProtocolWrapper
следующих ссылок и protocol
, служащих как Client
. Список должен расти вниз от OnionProtocol
и всегда заканчиваться на OnionClient
. Ошибка возникает из-за того, что этот инвариант упорядочения нарушен.
Одиночный список хорош для толкания протоколов в стек, но неудобно их выталкивать, потому что для его удаления требуется обход вниз от головы до node. Конечно, этот обход происходит каждый раз, когда данные получены, поэтому проблема заключается в сложности, обусловленной дополнительным обходом, а не сложностью временного времени. К счастью, список на самом деле дважды связан:
Атрибут transport
связывает каждый вложенный протокол с его предшественником, так что transport.write
может сложить на последовательно более низкие уровни шифрования, прежде чем, наконец, отправить данные по сети. У нас есть два часовых, чтобы помочь в управлении списком: Client
всегда должен быть наверху, а OnionClient
всегда должен быть внизу.
Объединяя два, мы получим следующее:
from twisted.python.components import proxyForInterface
from twisted.internet.interfaces import ITCPTransport
from twisted.protocols.tls import TLSMemoryBIOFactory, TLSMemoryBIOProtocol
from twisted.protocols.policies import ProtocolWrapper, WrappingFactory
class PopOnDisconnectTransport(proxyForInterface(ITCPTransport)):
"""
L{TLSMemoryBIOProtocol.loseConnection} shuts down the TLS session
and calls its own transport C{loseConnection}. A zero-length
read also calls the transport C{loseConnection}. This proxy
uses that behavior to invoke a C{pop} callback when a session has
ended. The callback is invoked exactly once because
C{loseConnection} must be idempotent.
"""
def __init__(self, pop, **kwargs):
super(PopOnDisconnectTransport, self).__init__(**kwargs)
self._pop = pop
def loseConnection(self):
self._pop()
self._pop = lambda: None
class OnionProtocol(ProtocolWrapper):
"""
OnionProtocol is both a transport and a protocol. As a protocol,
it can run over any other ITransport. As a transport, it
implements stackable TLS. That is, whatever application traffic
is generated by the protocol running on top of OnionProtocol can
be encapsulated in a TLS conversation. Or, that TLS conversation
can be encapsulated in another TLS conversation. Or **that** TLS
conversation can be encapsulated in yet *another* TLS
conversation.
Each layer of TLS can use different connection parameters, such as
keys, ciphers, certificate requirements, etc. At the remote end
of this connection, each has to be decrypted separately, starting
at the outermost and working in. OnionProtocol can do this
itself, of course, just as it can encrypt each layer starting with
the innermost.
"""
def __init__(self, *args, **kwargs):
ProtocolWrapper.__init__(self, *args, **kwargs)
# The application level protocol is the sentinel at the tail
# of the linked list stack of protocol wrappers. The stack
# begins at this sentinel.
self._tailProtocol = self._currentProtocol = self.wrappedProtocol
def startTLS(self, contextFactory, client, bytes=None):
"""
Add a layer of TLS, with SSL parameters defined by the given
contextFactory.
If *client* is True, this side of the connection will be an
SSL client. Otherwise it will be an SSL server.
If extra bytes which may be (or almost certainly are) part of
the SSL handshake were received by the protocol running on top
of OnionProtocol, they must be passed here as the **bytes**
parameter.
"""
# The newest TLS session is spliced in between the previous
# and the application protocol at the tail end of the list.
tlsProtocol = TLSMemoryBIOProtocol(None, self._tailProtocol, False)
tlsProtocol.factory = TLSMemoryBIOFactory(contextFactory, client, None)
if self._currentProtocol is self._tailProtocol:
# This is the first and thus outermost TLS session. The
# transport is the immutable sentinel that no startTLS or
# stopTLS call will move within the linked list stack.
# The wrappedProtocol will remain this outermost session
# until it terminated.
self.wrappedProtocol = tlsProtocol
nextTransport = PopOnDisconnectTransport(
original=self.transport,
pop=self._pop
)
# Store the proxied transport as the list head sentinel
# to enable an easy identity check in _pop.
self._headTransport = nextTransport
else:
# This a later TLS session within the stack. The previous
# TLS session becomes its transport.
nextTransport = PopOnDisconnectTransport(
original=self._currentProtocol,
pop=self._pop
)
# Splice the new TLS session into the linked list stack.
# wrappedProtocol serves as the link, so the protocol at the
# current position takes our new TLS session as its
# wrappedProtocol.
self._currentProtocol.wrappedProtocol = tlsProtocol
# Move down one position in the linked list.
self._currentProtocol = tlsProtocol
# Expose the new, innermost TLS session as the transport to
# the application protocol.
self.transport = self._currentProtocol
# Connect the new TLS session to the previous transport. The
# transport attribute also serves as the previous link.
tlsProtocol.makeConnection(nextTransport)
# Left over bytes are part of the latest handshake. Pass them
# on to the innermost TLS session.
if bytes is not None:
tlsProtocol.dataReceived(bytes)
def stopTLS(self):
self.transport.loseConnection()
def _pop(self):
pop = self._currentProtocol
previous = pop.transport
# If the previous link is the head sentinel, we've run out of
# linked list. Ensure that the application protocol, stored
# as the tail sentinel, becomes the wrappedProtocol, and the
# head sentinel, which is the underlying transport, becomes
# the transport.
if previous is self._headTransport:
self._currentProtocol = self.wrappedProtocol = self._tailProtocol
self.transport = previous
else:
# Splice out a protocol from the linked list stack. The
# previous transport is a PopOnDisconnectTransport proxy,
# so first retrieve proxied object off its original
# attribute.
previousProtocol = previous.original
# The previous protocol next link becomes the popped
# protocol next link
previousProtocol.wrappedProtocol = pop.wrappedProtocol
# Move up one position in the linked list.
self._currentProtocol = previousProtocol
# Expose the new, innermost TLS session as the transport
# to the application protocol.
self.transport = self._currentProtocol
class OnionFactory(WrappingFactory):
"""
A L{WrappingFactory} that overrides
L{WrappingFactory.registerProtocol} and
L{WrappingFactory.unregisterProtocol}. These methods store in and
remove from a dictionary L{ProtocolWrapper} instances. The
C{transport} patching done as part of the linked-list management
above causes the instances' hash to change, because the
C{__hash__} is proxied through to the wrapped transport. They're
not essential to this program, so the easiest solution is to make
them do nothing.
"""
protocol = OnionProtocol
def registerProtocol(self, protocol):
pass
def unregisterProtocol(self, protocol):
pass
(Это также доступно на GitHub.)
Решение второй задачи лежит в PopOnDisconnectTransport
. Исходный код попытался вывести сеанс TLS из стека через connectionLost
, но поскольку только закрытый дескриптор файла вызывает вызов connectionLost
, ему не удалось удалить остановленные сеансы TLS, которые не закрывали базовый сокет.
На момент написания этой статьи TLSMemoryBIOProtocol
называет его транспорт loseConnection
ровно в двух местах: _shutdownTLS
и _tlsShutdownFinished
. _shutdownTLS
вызывается в активных закрывает (loseConnection
, abortConnection
, unregisterProducer
и после loseConnection
и все ожидающие рассмотрения записи были сброшены), а _tlsShutdownFinished
вызывается при пассивном закрытии (сбои установления связи, пустое чтение, читать ошибки и записать ошибки). Все это означает, что обе стороны закрытого соединения могут поп остановить сеансы TLS со стека во время loseConnection
. PopOnDisconnectTransport
делает это идемпотентно, потому что loseConnection
обычно идемпотентен, и TLSMemoryBIOProtocol
, безусловно, ожидает, что он будет.
Недостатком логики управления стеком в loseConnection
является то, что она зависит от особенностей реализации TLSMemoryBIOProtocol
. Для обобщенного решения потребуются новые API-интерфейсы на многих уровнях Twisted.
До тех пор мы придерживаемся еще одного примера Закона о хираме.