wireprotoserver: handle SSH protocol version 2 upgrade requests
authorGregory Szorc <gregory.szorc@gmail.com>
Mon, 12 Feb 2018 16:33:54 -0800
changeset 36253 464bedc0fdb4
parent 36252 3b3a987bbbaa
child 36254 7218e93ade47
wireprotoserver: handle SSH protocol version 2 upgrade requests This commit teaches the SSH server to recognize the "upgrade" request line that clients send when they wish to switch the channel to version 2 of the SSH protocol. Servers don't honor upgrade requests unless an experimental config option is set. Since the built-in server now supports upgrade requests, our test server to test the handshake has been deleted. Existing tests use the built-in server and their output doesn't change. The upgrade is handled in our state machine. The end result is a bit wonky, as the server transitions back to version 1 state immediately after upgrading. But this will change as soon as version 2 has an actual protocol that differs from version 1. Tests demonstrating that the new server is a bit more strict about the upgrade handshake have been added. Differential Revision: https://phab.mercurial-scm.org/D2204
mercurial/configitems.py
mercurial/wireprotoserver.py
tests/sshprotoext.py
tests/test-ssh-proto.t
--- a/mercurial/configitems.py	Thu Feb 08 15:09:59 2018 -0800
+++ b/mercurial/configitems.py	Mon Feb 12 16:33:54 2018 -0800
@@ -556,6 +556,9 @@
 coreconfigitem('experimental', 'single-head-per-branch',
     default=False,
 )
+coreconfigitem('experimental', 'sshserver.support-v2',
+    default=False,
+)
 coreconfigitem('experimental', 'spacemovesdown',
     default=False,
 )
--- a/mercurial/wireprotoserver.py	Thu Feb 08 15:09:59 2018 -0800
+++ b/mercurial/wireprotoserver.py	Mon Feb 12 16:33:54 2018 -0800
@@ -409,9 +409,65 @@
         client = encoding.environ.get('SSH_CLIENT', '').split(' ', 1)[0]
         return 'remote:ssh:' + client
 
+class sshv2protocolhandler(sshv1protocolhandler):
+    """Protocol handler for version 2 of the SSH protocol."""
+
 def _runsshserver(ui, repo, fin, fout):
+    # This function operates like a state machine of sorts. The following
+    # states are defined:
+    #
+    # protov1-serving
+    #    Server is in protocol version 1 serving mode. Commands arrive on
+    #    new lines. These commands are processed in this state, one command
+    #    after the other.
+    #
+    # protov2-serving
+    #    Server is in protocol version 2 serving mode.
+    #
+    # upgrade-initial
+    #    The server is going to process an upgrade request.
+    #
+    # upgrade-v2-filter-legacy-handshake
+    #    The protocol is being upgraded to version 2. The server is expecting
+    #    the legacy handshake from version 1.
+    #
+    # upgrade-v2-finish
+    #    The upgrade to version 2 of the protocol is imminent.
+    #
+    # shutdown
+    #    The server is shutting down, possibly in reaction to a client event.
+    #
+    # And here are their transitions:
+    #
+    # protov1-serving -> shutdown
+    #    When server receives an empty request or encounters another
+    #    error.
+    #
+    # protov1-serving -> upgrade-initial
+    #    An upgrade request line was seen.
+    #
+    # upgrade-initial -> upgrade-v2-filter-legacy-handshake
+    #    Upgrade to version 2 in progress. Server is expecting to
+    #    process a legacy handshake.
+    #
+    # upgrade-v2-filter-legacy-handshake -> shutdown
+    #    Client did not fulfill upgrade handshake requirements.
+    #
+    # upgrade-v2-filter-legacy-handshake -> upgrade-v2-finish
+    #    Client fulfilled version 2 upgrade requirements. Finishing that
+    #    upgrade.
+    #
+    # upgrade-v2-finish -> protov2-serving
+    #    Protocol upgrade to version 2 complete. Server can now speak protocol
+    #    version 2.
+    #
+    # protov2-serving -> protov1-serving
+    #    Ths happens by default since protocol version 2 is the same as
+    #    version 1 except for the handshake.
+
     state = 'protov1-serving'
     proto = sshv1protocolhandler(ui, fin, fout)
+    protoswitched = False
 
     while True:
         if state == 'protov1-serving':
@@ -423,6 +479,19 @@
                 state = 'shutdown'
                 continue
 
+            # It looks like a protocol upgrade request. Transition state to
+            # handle it.
+            if request.startswith(b'upgrade '):
+                if protoswitched:
+                    _sshv1respondooberror(fout, ui.ferr,
+                                          b'cannot upgrade protocols multiple '
+                                          b'times')
+                    state = 'shutdown'
+                    continue
+
+                state = 'upgrade-initial'
+                continue
+
             available = wireproto.commands.commandavailable(request, proto)
 
             # This command isn't available. Send an empty response and go
@@ -452,6 +521,103 @@
                 raise error.ProgrammingError('unhandled response type from '
                                              'wire protocol command: %s' % rsp)
 
+        # For now, protocol version 2 serving just goes back to version 1.
+        elif state == 'protov2-serving':
+            state = 'protov1-serving'
+            continue
+
+        elif state == 'upgrade-initial':
+            # We should never transition into this state if we've switched
+            # protocols.
+            assert not protoswitched
+            assert proto.name == SSHV1
+
+            # Expected: upgrade <token> <capabilities>
+            # If we get something else, the request is malformed. It could be
+            # from a future client that has altered the upgrade line content.
+            # We treat this as an unknown command.
+            try:
+                token, caps = request.split(b' ')[1:]
+            except ValueError:
+                _sshv1respondbytes(fout, b'')
+                state = 'protov1-serving'
+                continue
+
+            # Send empty response if we don't support upgrading protocols.
+            if not ui.configbool('experimental', 'sshserver.support-v2'):
+                _sshv1respondbytes(fout, b'')
+                state = 'protov1-serving'
+                continue
+
+            try:
+                caps = urlreq.parseqs(caps)
+            except ValueError:
+                _sshv1respondbytes(fout, b'')
+                state = 'protov1-serving'
+                continue
+
+            # We don't see an upgrade request to protocol version 2. Ignore
+            # the upgrade request.
+            wantedprotos = caps.get(b'proto', [b''])[0]
+            if SSHV2 not in wantedprotos:
+                _sshv1respondbytes(fout, b'')
+                state = 'protov1-serving'
+                continue
+
+            # It looks like we can honor this upgrade request to protocol 2.
+            # Filter the rest of the handshake protocol request lines.
+            state = 'upgrade-v2-filter-legacy-handshake'
+            continue
+
+        elif state == 'upgrade-v2-filter-legacy-handshake':
+            # Client should have sent legacy handshake after an ``upgrade``
+            # request. Expected lines:
+            #
+            #    hello
+            #    between
+            #    pairs 81
+            #    0000...-0000...
+
+            ok = True
+            for line in (b'hello', b'between', b'pairs 81'):
+                request = fin.readline()[:-1]
+
+                if request != line:
+                    _sshv1respondooberror(fout, ui.ferr,
+                                          b'malformed handshake protocol: '
+                                          b'missing %s' % line)
+                    ok = False
+                    state = 'shutdown'
+                    break
+
+            if not ok:
+                continue
+
+            request = fin.read(81)
+            if request != b'%s-%s' % (b'0' * 40, b'0' * 40):
+                _sshv1respondooberror(fout, ui.ferr,
+                                      b'malformed handshake protocol: '
+                                      b'missing between argument value')
+                state = 'shutdown'
+                continue
+
+            state = 'upgrade-v2-finish'
+            continue
+
+        elif state == 'upgrade-v2-finish':
+            # Send the upgrade response.
+            fout.write(b'upgraded %s %s\n' % (token, SSHV2))
+            servercaps = wireproto.capabilities(repo, proto)
+            rsp = b'capabilities: %s' % servercaps.data
+            fout.write(b'%d\n%s\n' % (len(rsp), rsp))
+            fout.flush()
+
+            proto = sshv2protocolhandler(ui, fin, fout)
+            protoswitched = True
+
+            state = 'protov2-serving'
+            continue
+
         elif state == 'shutdown':
             break
 
--- a/tests/sshprotoext.py	Thu Feb 08 15:09:59 2018 -0800
+++ b/tests/sshprotoext.py	Mon Feb 12 16:33:54 2018 -0800
@@ -55,37 +55,6 @@
 
         super(prehelloserver, self).serve_forever()
 
-class upgradev2server(wireprotoserver.sshserver):
-    """Tests behavior for clients that issue upgrade to version 2."""
-    def serve_forever(self):
-        name = wireprotoserver.SSHV2
-        l = self._fin.readline()
-        assert l.startswith(b'upgrade ')
-        token, caps = l[:-1].split(b' ')[1:]
-        assert caps == b'proto=%s' % name
-
-        # Filter hello and between requests.
-        l = self._fin.readline()
-        assert l == b'hello\n'
-        l = self._fin.readline()
-        assert l == b'between\n'
-        l = self._fin.readline()
-        assert l == b'pairs 81\n'
-        self._fin.read(81)
-
-        # Send the upgrade response.
-        proto = wireprotoserver.sshv1protocolhandler(self._ui, self._fin,
-                                                     self._fout)
-        self._fout.write(b'upgraded %s %s\n' % (token, name))
-        servercaps = wireproto.capabilities(self._repo, proto)
-        rsp = b'capabilities: %s' % servercaps.data
-        self._fout.write(b'%d\n' % len(rsp))
-        self._fout.write(rsp)
-        self._fout.write(b'\n')
-        self._fout.flush()
-
-        super(upgradev2server, self).serve_forever()
-
 def performhandshake(orig, ui, stdin, stdout, stderr):
     """Wrapped version of sshpeer._performhandshake to send extra commands."""
     mode = ui.config(b'sshpeer', b'handshake-mode')
@@ -118,8 +87,6 @@
         wireprotoserver.sshserver = bannerserver
     elif servermode == b'no-hello':
         wireprotoserver.sshserver = prehelloserver
-    elif servermode == b'upgradev2':
-        wireprotoserver.sshserver = upgradev2server
     elif servermode:
         raise error.ProgrammingError(b'unknown server mode: %s' % servermode)
 
--- a/tests/test-ssh-proto.t	Thu Feb 08 15:09:59 2018 -0800
+++ b/tests/test-ssh-proto.t	Mon Feb 12 16:33:54 2018 -0800
@@ -453,9 +453,17 @@
   local: no
   pushable: yes
 
+Enable version 2 support on server. We need to do this in hgrc because we can't
+use --config with `hg serve --stdio`.
+
+  $ cat >> server/.hg/hgrc << EOF
+  > [experimental]
+  > sshserver.support-v2 = true
+  > EOF
+
 Send an upgrade request to a server that supports upgrade
 
-  $ SSHSERVERMODE=upgradev2 hg -R server serve --stdio << EOF
+  $ hg -R server serve --stdio << EOF
   > upgrade this-is-some-token proto=exp-ssh-v2-0001
   > hello
   > between
@@ -466,7 +474,7 @@
   383
   capabilities: lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch streamreqs=generaldelta,revlogv1 $USUAL_BUNDLE2_CAPS_SERVER$ unbundle=HG10GZ,HG10BZ,HG10UN
 
-  $ SSHSERVERMODE=upgradev2 hg --config experimental.sshpeer.advertise-v2=true --debug debugpeer ssh://user@dummy/server
+  $ hg --config experimental.sshpeer.advertise-v2=true --debug debugpeer ssh://user@dummy/server
   running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
   running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
   sending upgrade request: * proto=exp-ssh-v2-0001 (glob)
@@ -482,7 +490,7 @@
 
 Verify the peer has capabilities
 
-  $ SSHSERVERMODE=upgradev2 hg --config experimental.sshpeer.advertise-v2=true --debug debugcapabilities ssh://user@dummy/server
+  $ hg --config experimental.sshpeer.advertise-v2=true --debug debugcapabilities ssh://user@dummy/server
   running * "*/tests/dummyssh" 'user@dummy' 'hg -R server serve --stdio' (glob) (no-windows !)
   running * "*\tests/dummyssh" "user@dummy" "hg -R server serve --stdio" (glob) (windows !)
   sending upgrade request: * proto=exp-ssh-v2-0001 (glob)
@@ -527,3 +535,96 @@
     remote-changegroup
       http
       https
+
+Command after upgrade to version 2 is processed
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade this-is-some-token proto=exp-ssh-v2-0001
+  > hello
+  > between
+  > pairs 81
+  > 0000000000000000000000000000000000000000-0000000000000000000000000000000000000000hello
+  > EOF
+  upgraded this-is-some-token exp-ssh-v2-0001
+  383
+  capabilities: lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch streamreqs=generaldelta,revlogv1 $USUAL_BUNDLE2_CAPS_SERVER$ unbundle=HG10GZ,HG10BZ,HG10UN
+  384
+  capabilities: lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch streamreqs=generaldelta,revlogv1 $USUAL_BUNDLE2_CAPS_SERVER$ unbundle=HG10GZ,HG10BZ,HG10UN
+
+Multiple upgrades is not allowed
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade this-is-some-token proto=exp-ssh-v2-0001
+  > hello
+  > between
+  > pairs 81
+  > 0000000000000000000000000000000000000000-0000000000000000000000000000000000000000upgrade another-token proto=irrelevant
+  > hello
+  > EOF
+  upgraded this-is-some-token exp-ssh-v2-0001
+  383
+  capabilities: lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch streamreqs=generaldelta,revlogv1 $USUAL_BUNDLE2_CAPS_SERVER$ unbundle=HG10GZ,HG10BZ,HG10UN
+  cannot upgrade protocols multiple times
+  -
+  
+
+Malformed upgrade request line (not exactly 3 space delimited tokens)
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade
+  > EOF
+  0
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade token
+  > EOF
+  0
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade token foo=bar extra-token
+  > EOF
+  0
+
+Upgrade request to unsupported protocol is ignored
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade this-is-some-token proto=unknown1,unknown2
+  > hello
+  > between
+  > pairs 81
+  > 0000000000000000000000000000000000000000-0000000000000000000000000000000000000000
+  > EOF
+  0
+  384
+  capabilities: lookup changegroupsubset branchmap pushkey known getbundle unbundlehash batch streamreqs=generaldelta,revlogv1 $USUAL_BUNDLE2_CAPS_SERVER$ unbundle=HG10GZ,HG10BZ,HG10UN
+  1
+  
+
+Upgrade request must be followed by hello + between
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade token proto=exp-ssh-v2-0001
+  > invalid
+  > EOF
+  malformed handshake protocol: missing hello
+  -
+  
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade token proto=exp-ssh-v2-0001
+  > hello
+  > invalid
+  > EOF
+  malformed handshake protocol: missing between
+  -
+  
+
+  $ hg -R server serve --stdio << EOF
+  > upgrade token proto=exp-ssh-v2-0001
+  > hello
+  > between
+  > invalid
+  > EOF
+  malformed handshake protocol: missing pairs 81
+  -
+