This commit is contained in:
2024-12-17 14:36:15 -08:00
parent b2dbf46d28
commit 06d106de53
17731 changed files with 3037186 additions and 144 deletions

View File

@@ -0,0 +1,7 @@
# -*- test-case-name: twisted.conch.test -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Twisted Conch: The Twisted Shell. Terminal emulation, SSHv2 and telnet.
"""

View File

@@ -0,0 +1,56 @@
# -*- test-case-name: twisted.conch.test.test_conch -*-
from zope.interface import implementer
from twisted.conch.error import ConchError
from twisted.conch.interfaces import IConchUser
from twisted.conch.ssh.connection import OPEN_UNKNOWN_CHANNEL_TYPE
from twisted.logger import Logger
from twisted.python.compat import nativeString
@implementer(IConchUser)
class ConchUser:
_log = Logger()
def __init__(self):
self.channelLookup = {}
self.subsystemLookup = {}
@property
def conn(self):
return self._conn
@conn.setter
def conn(self, value):
self._conn = value
def lookupChannel(self, channelType, windowSize, maxPacket, data):
klass = self.channelLookup.get(channelType, None)
if not klass:
raise ConchError(OPEN_UNKNOWN_CHANNEL_TYPE, "unknown channel")
else:
return klass(
remoteWindow=windowSize,
remoteMaxPacket=maxPacket,
data=data,
avatar=self,
)
def lookupSubsystem(self, subsystem, data):
self._log.debug(
"Subsystem lookup: {subsystem!r}", subsystem=self.subsystemLookup
)
klass = self.subsystemLookup.get(subsystem, None)
if not klass:
return False
return klass(data, avatar=self)
def gotGlobalRequest(self, requestType, data):
# XXX should this use method dispatch?
requestType = nativeString(requestType.replace(b"-", b"_"))
f = getattr(self, "global_%s" % requestType, None)
if not f:
return 0
return f(data)

View File

@@ -0,0 +1,640 @@
# -*- test-case-name: twisted.conch.test.test_checkers -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Provide L{ICredentialsChecker} implementations to be used in Conch protocols.
"""
import binascii
import errno
import sys
from base64 import decodebytes
from typing import IO, Any, Callable, Iterable, Iterator, Mapping, Optional, Tuple, cast
from zope.interface import Interface, implementer, providedBy
from incremental import Version
from typing_extensions import Literal, Protocol
from twisted.conch import error
from twisted.conch.ssh import keys
from twisted.cred.checkers import ICredentialsChecker
from twisted.cred.credentials import ISSHPrivateKey, IUsernamePassword
from twisted.cred.error import UnauthorizedLogin, UnhandledCredentials
from twisted.internet import defer
from twisted.logger import Logger
from twisted.plugins.cred_unix import verifyCryptedPassword
from twisted.python import failure, reflect
from twisted.python.deprecate import deprecatedModuleAttribute
from twisted.python.filepath import FilePath
from twisted.python.util import runAsEffectiveUser
_log = Logger()
class UserRecord(Tuple[str, str, int, int, str, str, str]):
"""
A record in a UNIX-style password database. See L{pwd} for field details.
This corresponds to the undocumented type L{pwd.struct_passwd}, but lacks named
field accessors.
"""
@property
def pw_dir(self) -> str: # type: ignore[empty-body]
...
class UserDB(Protocol):
"""
A database of users by name, like the stdlib L{pwd} module.
See L{twisted.python.fakepwd} for an in-memory implementation.
"""
def getpwnam(self, username: str) -> UserRecord:
"""
Lookup a user record by name.
@raises KeyError: when no such user exists
"""
pwd: Optional[UserDB]
try:
import pwd as _pwd
except ImportError:
pwd = None
else:
pwd = cast(UserDB, _pwd)
try:
import spwd as _spwd
except ImportError:
spwd = None
else:
spwd = _spwd
class CryptedPasswordRecord(Protocol):
"""
A sequence where the item at index 1 may be a crypted password.
Both L{pwd.struct_passwd} and L{spwd.struct_spwd} conform to this protocol.
"""
def __getitem__(self, index: Literal[1]) -> str:
"""
Get the crypted password.
"""
def _lookupUser(userdb: UserDB, username: bytes) -> UserRecord:
"""
Lookup a user by name in a L{pwd}-style database.
@param userdb: The user database.
@param username: Identifying name in bytes. This will be decoded according
to the filesystem encoding, as the L{pwd} module does internally.
@raises KeyError: when the user doesn't exist
"""
return userdb.getpwnam(username.decode(sys.getfilesystemencoding()))
def _pwdGetByName(username: str) -> Optional[CryptedPasswordRecord]:
"""
Look up a user in the /etc/passwd database using the pwd module. If the
pwd module is not available, return None.
@param username: the username of the user to return the passwd database
information for.
@returns: A L{pwd.struct_passwd}, where field 1 may contain a crypted
password, or L{None} when the L{pwd} database is unavailable.
@raises KeyError: when no such user exists
"""
if pwd is None:
return None
return cast(CryptedPasswordRecord, pwd.getpwnam(username))
def _shadowGetByName(username: str) -> Optional[CryptedPasswordRecord]:
"""
Look up a user in the /etc/shadow database using the spwd module. If it is
not available, return L{None}.
@param username: the username of the user to return the shadow database
information for.
@type username: L{str}
@returns: A L{spwd.struct_spwd}, where field 1 may contain a crypted
password, or L{None} when the L{spwd} database is unavailable.
@raises KeyError: when no such user exists
"""
if spwd is not None:
f = spwd.getspnam
else:
return None
return cast(CryptedPasswordRecord, runAsEffectiveUser(0, 0, f, username))
@implementer(ICredentialsChecker)
class UNIXPasswordDatabase:
"""
A checker which validates users out of the UNIX password databases, or
databases of a compatible format.
@ivar _getByNameFunctions: a C{list} of functions which are called in order
to validate a user. The default value is such that the C{/etc/passwd}
database will be tried first, followed by the C{/etc/shadow} database.
"""
credentialInterfaces = (IUsernamePassword,)
def __init__(self, getByNameFunctions=None):
if getByNameFunctions is None:
getByNameFunctions = [_pwdGetByName, _shadowGetByName]
self._getByNameFunctions = getByNameFunctions
def requestAvatarId(self, credentials):
# We get bytes, but the Py3 pwd module uses str. So attempt to decode
# it using the same method that CPython does for the file on disk.
username = credentials.username.decode(sys.getfilesystemencoding())
password = credentials.password.decode(sys.getfilesystemencoding())
for func in self._getByNameFunctions:
try:
pwnam = func(username)
except KeyError:
return defer.fail(UnauthorizedLogin("invalid username"))
else:
if pwnam is not None:
crypted = pwnam[1]
if crypted == "":
continue
if verifyCryptedPassword(crypted, password):
return defer.succeed(credentials.username)
# fallback
return defer.fail(UnauthorizedLogin("unable to verify password"))
@implementer(ICredentialsChecker)
class SSHPublicKeyDatabase:
"""
Checker that authenticates SSH public keys, based on public keys listed in
authorized_keys and authorized_keys2 files in user .ssh/ directories.
"""
credentialInterfaces = (ISSHPrivateKey,)
_userdb: UserDB = cast(UserDB, pwd)
def requestAvatarId(self, credentials):
d = defer.maybeDeferred(self.checkKey, credentials)
d.addCallback(self._cbRequestAvatarId, credentials)
d.addErrback(self._ebRequestAvatarId)
return d
def _cbRequestAvatarId(self, validKey, credentials):
"""
Check whether the credentials themselves are valid, now that we know
if the key matches the user.
@param validKey: A boolean indicating whether or not the public key
matches a key in the user's authorized_keys file.
@param credentials: The credentials offered by the user.
@type credentials: L{ISSHPrivateKey} provider
@raise UnauthorizedLogin: (as a failure) if the key does not match the
user in C{credentials}. Also raised if the user provides an invalid
signature.
@raise ValidPublicKey: (as a failure) if the key matches the user but
the credentials do not include a signature. See
L{error.ValidPublicKey} for more information.
@return: The user's username, if authentication was successful.
"""
if not validKey:
return failure.Failure(UnauthorizedLogin("invalid key"))
if not credentials.signature:
return failure.Failure(error.ValidPublicKey())
else:
try:
pubKey = keys.Key.fromString(credentials.blob)
if pubKey.verify(credentials.signature, credentials.sigData):
return credentials.username
except Exception: # any error should be treated as a failed login
_log.failure("Error while verifying key")
return failure.Failure(UnauthorizedLogin("error while verifying key"))
return failure.Failure(UnauthorizedLogin("unable to verify key"))
def getAuthorizedKeysFiles(self, credentials):
"""
Return a list of L{FilePath} instances for I{authorized_keys} files
which might contain information about authorized keys for the given
credentials.
On OpenSSH servers, the default location of the file containing the
list of authorized public keys is
U{$HOME/.ssh/authorized_keys<http://www.openbsd.org/cgi-bin/man.cgi?query=sshd_config>}.
I{$HOME/.ssh/authorized_keys2} is also returned, though it has been
U{deprecated by OpenSSH since
2001<http://marc.info/?m=100508718416162>}.
@return: A list of L{FilePath} instances to files with the authorized keys.
"""
pwent = _lookupUser(self._userdb, credentials.username)
root = FilePath(pwent.pw_dir).child(".ssh")
files = ["authorized_keys", "authorized_keys2"]
return [root.child(f) for f in files]
def checkKey(self, credentials):
"""
Retrieve files containing authorized keys and check against user
credentials.
"""
ouid, ogid = _lookupUser(self._userdb, credentials.username)[2:4]
for filepath in self.getAuthorizedKeysFiles(credentials):
if not filepath.exists():
continue
try:
lines = filepath.open()
except OSError as e:
if e.errno == errno.EACCES:
lines = runAsEffectiveUser(ouid, ogid, filepath.open)
else:
raise
with lines:
for l in lines:
l2 = l.split()
if len(l2) < 2:
continue
try:
if decodebytes(l2[1]) == credentials.blob:
return True
except binascii.Error:
continue
return False
def _ebRequestAvatarId(self, f):
if not f.check(UnauthorizedLogin):
_log.error(
"Unauthorized login due to internal error: {error}", error=f.value
)
return failure.Failure(UnauthorizedLogin("unable to get avatar id"))
return f
@implementer(ICredentialsChecker)
class SSHProtocolChecker:
"""
SSHProtocolChecker is a checker that requires multiple authentications
to succeed. To add a checker, call my registerChecker method with
the checker and the interface.
After each successful authenticate, I call my areDone method with the
avatar id. To get a list of the successful credentials for an avatar id,
use C{SSHProcotolChecker.successfulCredentials[avatarId]}. If L{areDone}
returns True, the authentication has succeeded.
"""
def __init__(self):
self.checkers = {}
self.successfulCredentials = {}
@property
def credentialInterfaces(self):
return list(self.checkers.keys())
def registerChecker(self, checker, *credentialInterfaces):
if not credentialInterfaces:
credentialInterfaces = checker.credentialInterfaces
for credentialInterface in credentialInterfaces:
self.checkers[credentialInterface] = checker
def requestAvatarId(self, credentials):
"""
Part of the L{ICredentialsChecker} interface. Called by a portal with
some credentials to check if they'll authenticate a user. We check the
interfaces that the credentials provide against our list of acceptable
checkers. If one of them matches, we ask that checker to verify the
credentials. If they're valid, we call our L{_cbGoodAuthentication}
method to continue.
@param credentials: the credentials the L{Portal} wants us to verify
"""
ifac = providedBy(credentials)
for i in ifac:
c = self.checkers.get(i)
if c is not None:
d = defer.maybeDeferred(c.requestAvatarId, credentials)
return d.addCallback(self._cbGoodAuthentication, credentials)
return defer.fail(
UnhandledCredentials(
"No checker for %s" % ", ".join(map(reflect.qual, ifac))
)
)
def _cbGoodAuthentication(self, avatarId, credentials):
"""
Called if a checker has verified the credentials. We call our
L{areDone} method to see if the whole of the successful authentications
are enough. If they are, we return the avatar ID returned by the first
checker.
"""
if avatarId not in self.successfulCredentials:
self.successfulCredentials[avatarId] = []
self.successfulCredentials[avatarId].append(credentials)
if self.areDone(avatarId):
del self.successfulCredentials[avatarId]
return avatarId
else:
raise error.NotEnoughAuthentication()
def areDone(self, avatarId):
"""
Override to determine if the authentication is finished for a given
avatarId.
@param avatarId: the avatar returned by the first checker. For
this checker to function correctly, all the checkers must
return the same avatar ID.
"""
return True
deprecatedModuleAttribute(
Version("Twisted", 15, 0, 0),
(
"Please use twisted.conch.checkers.SSHPublicKeyChecker, "
"initialized with an instance of "
"twisted.conch.checkers.UNIXAuthorizedKeysFiles instead."
),
__name__,
"SSHPublicKeyDatabase",
)
class IAuthorizedKeysDB(Interface):
"""
An object that provides valid authorized ssh keys mapped to usernames.
@since: 15.0
"""
def getAuthorizedKeys(avatarId):
"""
Gets an iterable of authorized keys that are valid for the given
C{avatarId}.
@param avatarId: the ID of the avatar
@type avatarId: valid return value of
L{twisted.cred.checkers.ICredentialsChecker.requestAvatarId}
@return: an iterable of L{twisted.conch.ssh.keys.Key}
"""
def readAuthorizedKeyFile(
fileobj: IO[bytes], parseKey: Callable[[bytes], keys.Key] = keys.Key.fromString
) -> Iterator[keys.Key]:
"""
Reads keys from an authorized keys file. Any non-comment line that cannot
be parsed as a key will be ignored, although that particular line will
be logged.
@param fileobj: something from which to read lines which can be parsed
as keys
@param parseKey: a callable that takes bytes and returns a
L{twisted.conch.ssh.keys.Key}, mainly to be used for testing. The
default is L{twisted.conch.ssh.keys.Key.fromString}.
@return: an iterable of L{twisted.conch.ssh.keys.Key}
@since: 15.0
"""
for line in fileobj:
line = line.strip()
if line and not line.startswith(b"#"): # for comments
try:
yield parseKey(line)
except keys.BadKeyError as e:
_log.error(
"Unable to parse line {line!r} as a key: {error!s}",
line=line,
error=e,
)
def _keysFromFilepaths(
filepaths: Iterable[FilePath[Any]], parseKey: Callable[[bytes], keys.Key]
) -> Iterable[keys.Key]:
"""
Helper function that turns an iterable of filepaths into a generator of
keys. If any file cannot be read, a message is logged but it is
otherwise ignored.
@param filepaths: iterable of L{twisted.python.filepath.FilePath}.
@type filepaths: iterable
@param parseKey: a callable that takes a string and returns a
L{twisted.conch.ssh.keys.Key}
@type parseKey: L{callable}
@return: generator of L{twisted.conch.ssh.keys.Key}
@since: 15.0
"""
for fp in filepaths:
if fp.exists():
try:
with fp.open() as f:
yield from readAuthorizedKeyFile(f, parseKey)
except OSError as e:
_log.error("Unable to read {path!r}: {error!s}", path=fp.path, error=e)
@implementer(IAuthorizedKeysDB)
class InMemorySSHKeyDB:
"""
Object that provides SSH public keys based on a dictionary of usernames
mapped to L{twisted.conch.ssh.keys.Key}s.
@since: 15.0
"""
def __init__(self, mapping: Mapping[bytes, Iterable[keys.Key]]) -> None:
"""
Initializes a new L{InMemorySSHKeyDB}.
@param mapping: mapping of usernames to iterables of
L{twisted.conch.ssh.keys.Key}s
"""
self._mapping = mapping
def getAuthorizedKeys(self, username: bytes) -> Iterable[keys.Key]:
"""
Look up the authorized keys for a user.
@param username: Name of the user
"""
return self._mapping.get(username, [])
@implementer(IAuthorizedKeysDB)
class UNIXAuthorizedKeysFiles:
"""
Object that provides SSH public keys based on public keys listed in
authorized_keys and authorized_keys2 files in UNIX user .ssh/ directories.
If any of the files cannot be read, a message is logged but that file is
otherwise ignored.
@since: 15.0
"""
_userdb: UserDB
def __init__(
self,
userdb: Optional[UserDB] = None,
parseKey: Callable[[bytes], keys.Key] = keys.Key.fromString,
):
"""
Initializes a new L{UNIXAuthorizedKeysFiles}.
@param userdb: access to the Unix user account and password database
(default is the Python module L{pwd}, if available)
@param parseKey: a callable that takes a string and returns a
L{twisted.conch.ssh.keys.Key}, mainly to be used for testing. The
default is L{twisted.conch.ssh.keys.Key.fromString}.
"""
if userdb is not None:
self._userdb = userdb
elif pwd is not None:
self._userdb = pwd
else:
raise ValueError("No pwd module found, and no userdb argument passed.")
self._parseKey = parseKey
def getAuthorizedKeys(self, username: bytes) -> Iterable[keys.Key]:
try:
passwd = _lookupUser(self._userdb, username)
except KeyError:
return ()
root = FilePath(passwd.pw_dir).child(".ssh")
files = ["authorized_keys", "authorized_keys2"]
return _keysFromFilepaths((root.child(f) for f in files), self._parseKey)
@implementer(ICredentialsChecker)
class SSHPublicKeyChecker:
"""
Checker that authenticates SSH public keys, based on public keys listed in
authorized_keys and authorized_keys2 files in user .ssh/ directories.
Initializing this checker with a L{UNIXAuthorizedKeysFiles} should be
used instead of L{twisted.conch.checkers.SSHPublicKeyDatabase}.
@since: 15.0
"""
credentialInterfaces = (ISSHPrivateKey,)
def __init__(self, keydb: IAuthorizedKeysDB) -> None:
"""
Initializes a L{SSHPublicKeyChecker}.
@param keydb: a provider of L{IAuthorizedKeysDB}
"""
self._keydb = keydb
def requestAvatarId(self, credentials):
d = defer.execute(self._sanityCheckKey, credentials)
d.addCallback(self._checkKey, credentials)
d.addCallback(self._verifyKey, credentials)
return d
def _sanityCheckKey(self, credentials):
"""
Checks whether the provided credentials are a valid SSH key with a
signature (does not actually verify the signature).
@param credentials: the credentials offered by the user
@type credentials: L{ISSHPrivateKey} provider
@raise ValidPublicKey: the credentials do not include a signature. See
L{error.ValidPublicKey} for more information.
@raise BadKeyError: The key included with the credentials is not
recognized as a key.
@return: the key in the credentials
@rtype: L{twisted.conch.ssh.keys.Key}
"""
if not credentials.signature:
raise error.ValidPublicKey()
return keys.Key.fromString(credentials.blob)
def _checkKey(self, pubKey, credentials):
"""
Checks the public key against all authorized keys (if any) for the
user.
@param pubKey: the key in the credentials (just to prevent it from
having to be calculated again)
@type pubKey:
@param credentials: the credentials offered by the user
@type credentials: L{ISSHPrivateKey} provider
@raise UnauthorizedLogin: If the key is not authorized, or if there
was any error obtaining a list of authorized keys for the user.
@return: C{pubKey} if the key is authorized
@rtype: L{twisted.conch.ssh.keys.Key}
"""
if any(
key == pubKey for key in self._keydb.getAuthorizedKeys(credentials.username)
):
return pubKey
raise UnauthorizedLogin("Key not authorized")
def _verifyKey(self, pubKey, credentials):
"""
Checks whether the credentials themselves are valid, now that we know
if the key matches the user.
@param pubKey: the key in the credentials (just to prevent it from
having to be calculated again)
@type pubKey: L{twisted.conch.ssh.keys.Key}
@param credentials: the credentials offered by the user
@type credentials: L{ISSHPrivateKey} provider
@raise UnauthorizedLogin: If the key signature is invalid or there
was any error verifying the signature.
@return: The user's username, if authentication was successful
@rtype: L{bytes}
"""
try:
if pubKey.verify(credentials.signature, credentials.sigData):
return credentials.username
except Exception as e: # Any error should be treated as a failed login
raise UnauthorizedLogin("Error while verifying key") from e
raise UnauthorizedLogin("Key signature invalid.")

View File

@@ -0,0 +1,9 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
"""
Client support code for Conch.
Maintainer: Paul Swartz
"""

View File

@@ -0,0 +1,65 @@
# -*- test-case-name: twisted.conch.test.test_default -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Accesses the key agent for user authentication.
Maintainer: Paul Swartz
"""
import os
from twisted.conch.ssh import agent, channel, keys
from twisted.internet import protocol, reactor
from twisted.logger import Logger
class SSHAgentClient(agent.SSHAgentClient):
_log = Logger()
def __init__(self):
agent.SSHAgentClient.__init__(self)
self.blobs = []
def getPublicKeys(self):
return self.requestIdentities().addCallback(self._cbPublicKeys)
def _cbPublicKeys(self, blobcomm):
self._log.debug("got {num_keys} public keys", num_keys=len(blobcomm))
self.blobs = [x[0] for x in blobcomm]
def getPublicKey(self):
"""
Return a L{Key} from the first blob in C{self.blobs}, if any, or
return L{None}.
"""
if self.blobs:
return keys.Key.fromString(self.blobs.pop(0))
return None
class SSHAgentForwardingChannel(channel.SSHChannel):
def channelOpen(self, specificData):
cc = protocol.ClientCreator(reactor, SSHAgentForwardingLocal)
d = cc.connectUNIX(os.environ["SSH_AUTH_SOCK"])
d.addCallback(self._cbGotLocal)
d.addErrback(lambda x: self.loseConnection())
self.buf = ""
def _cbGotLocal(self, local):
self.local = local
self.dataReceived = self.local.transport.write
self.local.dataReceived = self.write
def dataReceived(self, data):
self.buf += data
def closed(self):
if self.local:
self.local.loseConnection()
self.local = None
class SSHAgentForwardingLocal(protocol.Protocol):
pass

View File

@@ -0,0 +1,24 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
from twisted.conch.client import direct
connectTypes = {"direct": direct.connect}
def connect(host, port, options, verifyHostKey, userAuthObject):
useConnects = ["direct"]
return _ebConnect(
None, useConnects, host, port, options, verifyHostKey, userAuthObject
)
def _ebConnect(f, useConnects, host, port, options, vhk, uao):
if not useConnects:
return f
connectType = useConnects.pop(0)
f = connectTypes[connectType]
d = f(host, port, options, vhk, uao)
d.addErrback(_ebConnect, useConnects, host, port, options, vhk, uao)
return d

View File

@@ -0,0 +1,331 @@
# -*- test-case-name: twisted.conch.test.test_knownhosts,twisted.conch.test.test_default -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Various classes and functions for implementing user-interaction in the
command-line conch client.
You probably shouldn't use anything in this module directly, since it assumes
you are sitting at an interactive terminal. For example, to programmatically
interact with a known_hosts database, use L{twisted.conch.client.knownhosts}.
"""
import contextlib
import getpass
import io
import os
import sys
from base64 import decodebytes
from twisted.conch.client import agent
from twisted.conch.client.knownhosts import ConsoleUI, KnownHostsFile
from twisted.conch.error import ConchError
from twisted.conch.ssh import common, keys, userauth
from twisted.internet import defer, protocol, reactor
from twisted.python.compat import nativeString
from twisted.python.filepath import FilePath
# The default location of the known hosts file (probably should be parsed out
# of an ssh config file someday).
_KNOWN_HOSTS = "~/.ssh/known_hosts"
# This name is bound so that the unit tests can use 'patch' to override it.
_open = open
_input = input
def verifyHostKey(transport, host, pubKey, fingerprint):
"""
Verify a host's key.
This function is a gross vestige of some bad factoring in the client
internals. The actual implementation, and a better signature of this logic
is in L{KnownHostsFile.verifyHostKey}. This function is not deprecated yet
because the callers have not yet been rehabilitated, but they should
eventually be changed to call that method instead.
However, this function does perform two functions not implemented by
L{KnownHostsFile.verifyHostKey}. It determines the path to the user's
known_hosts file based on the options (which should really be the options
object's job), and it provides an opener to L{ConsoleUI} which opens
'/dev/tty' so that the user will be prompted on the tty of the process even
if the input and output of the process has been redirected. This latter
part is, somewhat obviously, not portable, but I don't know of a portable
equivalent that could be used.
@param host: Due to a bug in L{SSHClientTransport.verifyHostKey}, this is
always the dotted-quad IP address of the host being connected to.
@type host: L{str}
@param transport: the client transport which is attempting to connect to
the given host.
@type transport: L{SSHClientTransport}
@param fingerprint: the fingerprint of the given public key, in
xx:xx:xx:... format. This is ignored in favor of getting the fingerprint
from the key itself.
@type fingerprint: L{str}
@param pubKey: The public key of the server being connected to.
@type pubKey: L{str}
@return: a L{Deferred} which fires with C{1} if the key was successfully
verified, or fails if the key could not be successfully verified. Failure
types may include L{HostKeyChanged}, L{UserRejectedKey}, L{IOError} or
L{KeyboardInterrupt}.
"""
actualHost = transport.factory.options["host"]
actualKey = keys.Key.fromString(pubKey)
kh = KnownHostsFile.fromPath(
FilePath(
transport.factory.options["known-hosts"] or os.path.expanduser(_KNOWN_HOSTS)
)
)
ui = ConsoleUI(lambda: _open("/dev/tty", "r+b", buffering=0))
return kh.verifyHostKey(ui, actualHost, host, actualKey)
def isInKnownHosts(host, pubKey, options):
"""
Checks to see if host is in the known_hosts file for the user.
@return: 0 if it isn't, 1 if it is and is the same, 2 if it's changed.
@rtype: L{int}
"""
keyType = common.getNS(pubKey)[0]
retVal = 0
if not options["known-hosts"] and not os.path.exists(os.path.expanduser("~/.ssh/")):
print("Creating ~/.ssh directory...")
os.mkdir(os.path.expanduser("~/.ssh"))
kh_file = options["known-hosts"] or _KNOWN_HOSTS
try:
known_hosts = open(os.path.expanduser(kh_file), "rb")
except OSError:
return 0
with known_hosts:
for line in known_hosts.readlines():
split = line.split()
if len(split) < 3:
continue
hosts, hostKeyType, encodedKey = split[:3]
if host not in hosts.split(b","): # incorrect host
continue
if hostKeyType != keyType: # incorrect type of key
continue
try:
decodedKey = decodebytes(encodedKey)
except BaseException:
continue
if decodedKey == pubKey:
return 1
else:
retVal = 2
return retVal
def getHostKeyAlgorithms(host, options):
"""
Look in known_hosts for a key corresponding to C{host}.
This can be used to change the order of supported key types
in the KEXINIT packet.
@type host: L{str}
@param host: the host to check in known_hosts
@type options: L{twisted.conch.client.options.ConchOptions}
@param options: options passed to client
@return: L{list} of L{str} representing key types or L{None}.
"""
knownHosts = KnownHostsFile.fromPath(
FilePath(options["known-hosts"] or os.path.expanduser(_KNOWN_HOSTS))
)
keyTypes = []
for entry in knownHosts.iterentries():
if entry.matchesHost(host):
if entry.keyType not in keyTypes:
keyTypes.append(entry.keyType)
return keyTypes or None
class SSHUserAuthClient(userauth.SSHUserAuthClient):
def __init__(self, user, options, *args):
userauth.SSHUserAuthClient.__init__(self, user, *args)
self.keyAgent = None
self.options = options
self.usedFiles = []
if not options.identitys:
options.identitys = ["~/.ssh/id_rsa", "~/.ssh/id_dsa"]
def serviceStarted(self):
if "SSH_AUTH_SOCK" in os.environ and not self.options["noagent"]:
self._log.debug(
"using SSH agent {authSock!r}", authSock=os.environ["SSH_AUTH_SOCK"]
)
cc = protocol.ClientCreator(reactor, agent.SSHAgentClient)
d = cc.connectUNIX(os.environ["SSH_AUTH_SOCK"])
d.addCallback(self._setAgent)
d.addErrback(self._ebSetAgent)
else:
userauth.SSHUserAuthClient.serviceStarted(self)
def serviceStopped(self):
if self.keyAgent:
self.keyAgent.transport.loseConnection()
self.keyAgent = None
def _setAgent(self, a):
self.keyAgent = a
d = self.keyAgent.getPublicKeys()
d.addBoth(self._ebSetAgent)
return d
def _ebSetAgent(self, f):
userauth.SSHUserAuthClient.serviceStarted(self)
def _getPassword(self, prompt):
"""
Prompt for a password using L{getpass.getpass}.
@param prompt: Written on tty to ask for the input.
@type prompt: L{str}
@return: The input.
@rtype: L{str}
"""
with self._replaceStdoutStdin():
try:
p = getpass.getpass(prompt)
return p
except (KeyboardInterrupt, OSError):
print()
raise ConchError("PEBKAC")
def getPassword(self, prompt=None):
if prompt:
prompt = nativeString(prompt)
else:
prompt = "{}@{}'s password: ".format(
nativeString(self.user),
self.transport.transport.getPeer().host,
)
try:
# We don't know the encoding the other side is using,
# signaling that is not part of the SSH protocol. But
# using our defaultencoding is better than just going for
# ASCII.
p = self._getPassword(prompt).encode(sys.getdefaultencoding())
return defer.succeed(p)
except ConchError:
return defer.fail()
def getPublicKey(self):
"""
Get a public key from the key agent if possible, otherwise look in
the next configured identity file for one.
"""
if self.keyAgent:
key = self.keyAgent.getPublicKey()
if key is not None:
return key
files = [x for x in self.options.identitys if x not in self.usedFiles]
self._log.debug(
"public key identities: {identities}\n{files}",
identities=self.options.identitys,
files=files,
)
if not files:
return None
file = files[0]
self.usedFiles.append(file)
file = os.path.expanduser(file)
file += ".pub"
if not os.path.exists(file):
return self.getPublicKey() # try again
try:
return keys.Key.fromFile(file)
except keys.BadKeyError:
return self.getPublicKey() # try again
def signData(self, publicKey, signData):
"""
Extend the base signing behavior by using an SSH agent to sign the
data, if one is available.
@type publicKey: L{Key}
@type signData: L{bytes}
"""
if not self.usedFiles: # agent key
return self.keyAgent.signData(publicKey.blob(), signData)
else:
return userauth.SSHUserAuthClient.signData(self, publicKey, signData)
def getPrivateKey(self):
"""
Try to load the private key from the last used file identified by
C{getPublicKey}, potentially asking for the passphrase if the key is
encrypted.
"""
file = os.path.expanduser(self.usedFiles[-1])
if not os.path.exists(file):
return None
try:
return defer.succeed(keys.Key.fromFile(file))
except keys.EncryptedKeyError:
for i in range(3):
prompt = "Enter passphrase for key '%s': " % self.usedFiles[-1]
try:
p = self._getPassword(prompt).encode(sys.getfilesystemencoding())
return defer.succeed(keys.Key.fromFile(file, passphrase=p))
except (keys.BadKeyError, ConchError):
pass
return defer.fail(ConchError("bad password"))
raise
except KeyboardInterrupt:
print()
reactor.stop()
def getGenericAnswers(self, name, instruction, prompts):
responses = []
with self._replaceStdoutStdin():
if name:
print(name.decode("utf-8"))
if instruction:
print(instruction.decode("utf-8"))
for prompt, echo in prompts:
prompt = prompt.decode("utf-8")
if echo:
responses.append(_input(prompt))
else:
responses.append(getpass.getpass(prompt))
return defer.succeed(responses)
@classmethod
def _openTty(cls):
"""
Open /dev/tty as two streams one in read, one in write mode,
and return them.
@return: File objects for reading and writing to /dev/tty,
corresponding to standard input and standard output.
@rtype: A L{tuple} of L{io.TextIOWrapper} on Python 3.
"""
stdin = io.TextIOWrapper(open("/dev/tty", "rb"))
stdout = io.TextIOWrapper(open("/dev/tty", "wb"))
return stdin, stdout
@classmethod
@contextlib.contextmanager
def _replaceStdoutStdin(cls):
"""
Contextmanager that replaces stdout and stdin with /dev/tty
and resets them when it is done.
"""
oldout, oldin = sys.stdout, sys.stdin
sys.stdin, sys.stdout = cls._openTty()
try:
yield
finally:
sys.stdout.close()
sys.stdin.close()
sys.stdout, sys.stdin = oldout, oldin

View File

@@ -0,0 +1,98 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
from twisted.conch import error
from twisted.conch.ssh import transport
from twisted.internet import defer, protocol, reactor
class SSHClientFactory(protocol.ClientFactory):
def __init__(self, d, options, verifyHostKey, userAuthObject):
self.d = d
self.options = options
self.verifyHostKey = verifyHostKey
self.userAuthObject = userAuthObject
def clientConnectionLost(self, connector, reason):
if self.options["reconnect"]:
connector.connect()
def clientConnectionFailed(self, connector, reason):
if self.d is None:
return
d, self.d = self.d, None
d.errback(reason)
def buildProtocol(self, addr):
trans = SSHClientTransport(self)
if self.options["ciphers"]:
trans.supportedCiphers = self.options["ciphers"]
if self.options["macs"]:
trans.supportedMACs = self.options["macs"]
if self.options["compress"]:
trans.supportedCompressions[0:1] = ["zlib"]
if self.options["host-key-algorithms"]:
trans.supportedPublicKeys = self.options["host-key-algorithms"]
return trans
class SSHClientTransport(transport.SSHClientTransport):
def __init__(self, factory):
self.factory = factory
self.unixServer = None
def connectionLost(self, reason):
if self.unixServer:
d = self.unixServer.stopListening()
self.unixServer = None
else:
d = defer.succeed(None)
d.addCallback(
lambda x: transport.SSHClientTransport.connectionLost(self, reason)
)
def receiveError(self, code, desc):
if self.factory.d is None:
return
d, self.factory.d = self.factory.d, None
d.errback(error.ConchError(desc, code))
def sendDisconnect(self, code, reason):
if self.factory.d is None:
return
d, self.factory.d = self.factory.d, None
transport.SSHClientTransport.sendDisconnect(self, code, reason)
d.errback(error.ConchError(reason, code))
def receiveDebug(self, alwaysDisplay, message, lang):
self._log.debug(
"Received Debug Message: {message}",
message=message,
alwaysDisplay=alwaysDisplay,
lang=lang,
)
if alwaysDisplay: # XXX what should happen here?
print(message)
def verifyHostKey(self, pubKey, fingerprint):
return self.factory.verifyHostKey(
self, self.transport.getPeer().host, pubKey, fingerprint
)
def setService(self, service):
self._log.info("setting client server to {service}", service=service)
transport.SSHClientTransport.setService(self, service)
if service.name != "ssh-userauth" and self.factory.d is not None:
d, self.factory.d = self.factory.d, None
d.callback(None)
def connectionSecure(self):
self.requestService(self.factory.userAuthObject)
def connect(host, port, options, verifyHostKey, userAuthObject):
d = defer.Deferred()
factory = SSHClientFactory(d, options, verifyHostKey, userAuthObject)
reactor.connectTCP(host, port, factory)
return d

View File

@@ -0,0 +1,622 @@
# -*- test-case-name: twisted.conch.test.test_knownhosts -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
An implementation of the OpenSSH known_hosts database.
@since: 8.2
"""
from __future__ import annotations
import hmac
import sys
from binascii import Error as DecodeError, a2b_base64, b2a_base64
from contextlib import closing
from hashlib import sha1
from typing import IO, Callable, Literal
from zope.interface import implementer
from twisted.conch.error import HostKeyChanged, InvalidEntry, UserRejectedKey
from twisted.conch.interfaces import IKnownHostEntry
from twisted.conch.ssh.keys import BadKeyError, FingerprintFormats, Key
from twisted.internet import defer
from twisted.internet.defer import Deferred
from twisted.logger import Logger
from twisted.python.compat import nativeString
from twisted.python.filepath import FilePath
from twisted.python.randbytes import secureRandom
from twisted.python.util import FancyEqMixin
log = Logger()
def _b64encode(s):
"""
Encode a binary string as base64 with no trailing newline.
@param s: The string to encode.
@type s: L{bytes}
@return: The base64-encoded string.
@rtype: L{bytes}
"""
return b2a_base64(s).strip()
def _extractCommon(string):
"""
Extract common elements of base64 keys from an entry in a hosts file.
@param string: A known hosts file entry (a single line).
@type string: L{bytes}
@return: a 4-tuple of hostname data (L{bytes}), ssh key type (L{bytes}), key
(L{Key}), and comment (L{bytes} or L{None}). The hostname data is
simply the beginning of the line up to the first occurrence of
whitespace.
@rtype: L{tuple}
"""
elements = string.split(None, 2)
if len(elements) != 3:
raise InvalidEntry()
hostnames, keyType, keyAndComment = elements
splitkey = keyAndComment.split(None, 1)
if len(splitkey) == 2:
keyString, comment = splitkey
comment = comment.rstrip(b"\n")
else:
keyString = splitkey[0]
comment = None
key = Key.fromString(a2b_base64(keyString))
return hostnames, keyType, key, comment
class _BaseEntry:
"""
Abstract base of both hashed and non-hashed entry objects, since they
represent keys and key types the same way.
@ivar keyType: The type of the key; either ssh-dss or ssh-rsa.
@type keyType: L{bytes}
@ivar publicKey: The server public key indicated by this line.
@type publicKey: L{twisted.conch.ssh.keys.Key}
@ivar comment: Trailing garbage after the key line.
@type comment: L{bytes}
"""
def __init__(self, keyType, publicKey, comment):
self.keyType = keyType
self.publicKey = publicKey
self.comment = comment
def matchesKey(self, keyObject):
"""
Check to see if this entry matches a given key object.
@param keyObject: A public key object to check.
@type keyObject: L{Key}
@return: C{True} if this entry's key matches C{keyObject}, C{False}
otherwise.
@rtype: L{bool}
"""
return self.publicKey == keyObject
@implementer(IKnownHostEntry)
class PlainEntry(_BaseEntry):
"""
A L{PlainEntry} is a representation of a plain-text entry in a known_hosts
file.
@ivar _hostnames: the list of all host-names associated with this entry.
"""
def __init__(
self, hostnames: list[bytes], keyType: bytes, publicKey: Key, comment: bytes
):
self._hostnames: list[bytes] = hostnames
super().__init__(keyType, publicKey, comment)
@classmethod
def fromString(cls, string: bytes) -> PlainEntry:
"""
Parse a plain-text entry in a known_hosts file, and return a
corresponding L{PlainEntry}.
@param string: a space-separated string formatted like "hostname
key-type base64-key-data comment".
@raise DecodeError: if the key is not valid encoded as valid base64.
@raise InvalidEntry: if the entry does not have the right number of
elements and is therefore invalid.
@raise BadKeyError: if the key, once decoded from base64, is not
actually an SSH key.
@return: an IKnownHostEntry representing the hostname and key in the
input line.
@rtype: L{PlainEntry}
"""
hostnames, keyType, key, comment = _extractCommon(string)
self = cls(hostnames.split(b","), keyType, key, comment)
return self
def matchesHost(self, hostname: bytes | str) -> bool:
"""
Check to see if this entry matches a given hostname.
@param hostname: A hostname or IP address literal to check against this
entry.
@return: C{True} if this entry is for the given hostname or IP address,
C{False} otherwise.
"""
if isinstance(hostname, str):
hostname = hostname.encode("utf-8")
return hostname in self._hostnames
def toString(self) -> bytes:
"""
Implement L{IKnownHostEntry.toString} by recording the comma-separated
hostnames, key type, and base-64 encoded key.
@return: The string representation of this entry, with unhashed hostname
information.
"""
fields = [
b",".join(self._hostnames),
self.keyType,
_b64encode(self.publicKey.blob()),
]
if self.comment is not None:
fields.append(self.comment)
return b" ".join(fields)
@implementer(IKnownHostEntry)
class UnparsedEntry:
"""
L{UnparsedEntry} is an entry in a L{KnownHostsFile} which can't actually be
parsed; therefore it matches no keys and no hosts.
"""
def __init__(self, string):
"""
Create an unparsed entry from a line in a known_hosts file which cannot
otherwise be parsed.
"""
self._string = string
def matchesHost(self, hostname):
"""
Always returns False.
"""
return False
def matchesKey(self, key):
"""
Always returns False.
"""
return False
def toString(self):
"""
Returns the input line, without its newline if one was given.
@return: The string representation of this entry, almost exactly as was
used to initialize this entry but without a trailing newline.
@rtype: L{bytes}
"""
return self._string.rstrip(b"\n")
def _hmacedString(key, string):
"""
Return the SHA-1 HMAC hash of the given key and string.
@param key: The HMAC key.
@type key: L{bytes}
@param string: The string to be hashed.
@type string: L{bytes}
@return: The keyed hash value.
@rtype: L{bytes}
"""
hash = hmac.HMAC(key, digestmod=sha1)
if isinstance(string, str):
string = string.encode("utf-8")
hash.update(string)
return hash.digest()
@implementer(IKnownHostEntry)
class HashedEntry(_BaseEntry, FancyEqMixin):
"""
A L{HashedEntry} is a representation of an entry in a known_hosts file
where the hostname has been hashed and salted.
@ivar _hostSalt: the salt to combine with a hostname for hashing.
@ivar _hostHash: the hashed representation of the hostname.
@cvar MAGIC: the 'hash magic' string used to identify a hashed line in a
known_hosts file as opposed to a plaintext one.
"""
MAGIC = b"|1|"
compareAttributes = ("_hostSalt", "_hostHash", "keyType", "publicKey", "comment")
def __init__(
self,
hostSalt: bytes,
hostHash: bytes,
keyType: bytes,
publicKey: Key,
comment: bytes | None,
) -> None:
self._hostSalt = hostSalt
self._hostHash = hostHash
super().__init__(keyType, publicKey, comment)
@classmethod
def fromString(cls, string: bytes) -> HashedEntry:
"""
Load a hashed entry from a string representing a line in a known_hosts
file.
@param string: A complete single line from a I{known_hosts} file,
formatted as defined by OpenSSH.
@raise DecodeError: if the key, the hostname, or the is not valid
encoded as valid base64
@raise InvalidEntry: if the entry does not have the right number of
elements and is therefore invalid, or the host/hash portion
contains more items than just the host and hash.
@raise BadKeyError: if the key, once decoded from base64, is not
actually an SSH key.
@return: The newly created L{HashedEntry} instance, initialized with
the information from C{string}.
"""
stuff, keyType, key, comment = _extractCommon(string)
saltAndHash = stuff[len(cls.MAGIC) :].split(b"|")
if len(saltAndHash) != 2:
raise InvalidEntry()
hostSalt, hostHash = saltAndHash
self = cls(a2b_base64(hostSalt), a2b_base64(hostHash), keyType, key, comment)
return self
def matchesHost(self, hostname):
"""
Implement L{IKnownHostEntry.matchesHost} to compare the hash of the
input to the stored hash.
@param hostname: A hostname or IP address literal to check against this
entry.
@type hostname: L{bytes}
@return: C{True} if this entry is for the given hostname or IP address,
C{False} otherwise.
@rtype: L{bool}
"""
return hmac.compare_digest(
_hmacedString(self._hostSalt, hostname), self._hostHash
)
def toString(self):
"""
Implement L{IKnownHostEntry.toString} by base64-encoding the salt, host
hash, and key.
@return: The string representation of this entry, with the hostname part
hashed.
@rtype: L{bytes}
"""
fields = [
self.MAGIC
+ b"|".join([_b64encode(self._hostSalt), _b64encode(self._hostHash)]),
self.keyType,
_b64encode(self.publicKey.blob()),
]
if self.comment is not None:
fields.append(self.comment)
return b" ".join(fields)
class KnownHostsFile:
"""
A structured representation of an OpenSSH-format ~/.ssh/known_hosts file.
@ivar _added: A list of L{IKnownHostEntry} providers which have been added
to this instance in memory but not yet saved.
@ivar _clobber: A flag indicating whether the current contents of the save
path will be disregarded and potentially overwritten or not. If
C{True}, this will be done. If C{False}, entries in the save path will
be read and new entries will be saved by appending rather than
overwriting.
@type _clobber: L{bool}
@ivar _savePath: See C{savePath} parameter of L{__init__}.
"""
def __init__(self, savePath: FilePath[str]) -> None:
"""
Create a new, empty KnownHostsFile.
Unless you want to erase the current contents of C{savePath}, you want
to use L{KnownHostsFile.fromPath} instead.
@param savePath: The L{FilePath} to which to save new entries.
@type savePath: L{FilePath}
"""
self._added: list[IKnownHostEntry] = []
self._savePath = savePath
self._clobber = True
@property
def savePath(self) -> FilePath[str]:
"""
@see: C{savePath} parameter of L{__init__}
"""
return self._savePath
def iterentries(self):
"""
Iterate over the host entries in this file.
@return: An iterable the elements of which provide L{IKnownHostEntry}.
There is an element for each entry in the file as well as an element
for each added but not yet saved entry.
@rtype: iterable of L{IKnownHostEntry} providers
"""
for entry in self._added:
yield entry
if self._clobber:
return
try:
fp = self._savePath.open()
except OSError:
return
with fp:
for line in fp:
try:
if line.startswith(HashedEntry.MAGIC):
entry = HashedEntry.fromString(line)
else:
entry = PlainEntry.fromString(line)
except (DecodeError, InvalidEntry, BadKeyError):
entry = UnparsedEntry(line)
yield entry
def hasHostKey(self, hostname, key):
"""
Check for an entry with matching hostname and key.
@param hostname: A hostname or IP address literal to check for.
@type hostname: L{bytes}
@param key: The public key to check for.
@type key: L{Key}
@return: C{True} if the given hostname and key are present in this file,
C{False} if they are not.
@rtype: L{bool}
@raise HostKeyChanged: if the host key found for the given hostname
does not match the given key.
"""
for lineidx, entry in enumerate(self.iterentries(), -len(self._added)):
if entry.matchesHost(hostname) and entry.keyType == key.sshType():
if entry.matchesKey(key):
return True
else:
# Notice that lineidx is 0-based but HostKeyChanged.lineno
# is 1-based.
if lineidx < 0:
line = None
path = None
else:
line = lineidx + 1
path = self._savePath
raise HostKeyChanged(entry, path, line)
return False
def verifyHostKey(
self, ui: ConsoleUI, hostname: bytes, ip: bytes, key: Key
) -> Deferred[bool]:
"""
Verify the given host key for the given IP and host, asking for
confirmation from, and notifying, the given UI about changes to this
file.
@param ui: The user interface to request an IP address from.
@param hostname: The hostname that the user requested to connect to.
@param ip: The string representation of the IP address that is actually
being connected to.
@param key: The public key of the server.
@return: a L{Deferred} that fires with True when the key has been
verified, or fires with an errback when the key either cannot be
verified or has changed.
@rtype: L{Deferred}
"""
hhk = defer.execute(self.hasHostKey, hostname, key)
def gotHasKey(result: bool) -> bool | Deferred[bool]:
if result:
if not self.hasHostKey(ip, key):
addMessage = (
f"Warning: Permanently added the {key.type()} host key"
f" for IP address '{ip.decode()}' to the list of known"
" hosts.\n"
)
ui.warn(addMessage.encode("utf-8"))
self.addHostKey(ip, key)
self.save()
return result
else:
def promptResponse(response: bool) -> bool:
if response:
self.addHostKey(hostname, key)
self.addHostKey(ip, key)
self.save()
return response
else:
raise UserRejectedKey()
keytype: str = key.type()
if keytype == "EC":
keytype = "ECDSA"
prompt = (
"The authenticity of host '%s (%s)' "
"can't be established.\n"
"%s key fingerprint is SHA256:%s.\n"
"Are you sure you want to continue connecting (yes/no)? "
% (
nativeString(hostname),
nativeString(ip),
keytype,
key.fingerprint(format=FingerprintFormats.SHA256_BASE64),
)
)
proceed = ui.prompt(prompt.encode(sys.getdefaultencoding()))
return proceed.addCallback(promptResponse)
return hhk.addCallback(gotHasKey)
def addHostKey(self, hostname: bytes, key: Key) -> HashedEntry:
"""
Add a new L{HashedEntry} to the key database.
Note that you still need to call L{KnownHostsFile.save} if you wish
these changes to be persisted.
@param hostname: A hostname or IP address literal to associate with the
new entry.
@type hostname: L{bytes}
@param key: The public key to associate with the new entry.
@type key: L{Key}
@return: The L{HashedEntry} that was added.
@rtype: L{HashedEntry}
"""
salt = secureRandom(20)
keyType = key.sshType()
entry = HashedEntry(salt, _hmacedString(salt, hostname), keyType, key, None)
self._added.append(entry)
return entry
def save(self) -> None:
"""
Save this L{KnownHostsFile} to the path it was loaded from.
"""
p = self._savePath.parent()
if not p.isdir():
p.makedirs()
mode: Literal["a", "w"] = "w" if self._clobber else "a"
with self._savePath.open(mode) as hostsFileObj:
if self._added:
hostsFileObj.write(
b"\n".join([entry.toString() for entry in self._added]) + b"\n"
)
self._added = []
self._clobber = False
@classmethod
def fromPath(cls, path: FilePath[str]) -> KnownHostsFile:
"""
Create a new L{KnownHostsFile}, potentially reading existing known
hosts information from the given file.
@param path: A path object to use for both reading contents from and
later saving to. If no file exists at this path, it is not an
error; a L{KnownHostsFile} with no entries is returned.
@return: A L{KnownHostsFile} initialized with entries from C{path}.
"""
knownHosts = cls(path)
knownHosts._clobber = False
return knownHosts
class ConsoleUI:
"""
A UI object that can ask true/false questions and post notifications on the
console, to be used during key verification.
"""
def __init__(self, opener: Callable[[], IO[bytes]]):
"""
@param opener: A no-argument callable which should open a console
binary-mode file-like object to be used for reading and writing.
This initializes the C{opener} attribute.
@type opener: callable taking no arguments and returning a read/write
file-like object
"""
self.opener = opener
def prompt(self, text: bytes) -> Deferred[bool]:
"""
Write the given text as a prompt to the console output, then read a
result from the console input.
@param text: Something to present to a user to solicit a yes or no
response.
@type text: L{bytes}
@return: a L{Deferred} which fires with L{True} when the user answers
'yes' and L{False} when the user answers 'no'. It may errback if
there were any I/O errors.
"""
d = defer.succeed(None)
def body(ignored):
with closing(self.opener()) as f:
f.write(text)
while True:
answer = f.readline().strip().lower()
if answer == b"yes":
return True
elif answer in {b"no", b""}:
return False
else:
f.write(b"Please type 'yes' or 'no': ")
return d.addCallback(body)
def warn(self, text: bytes) -> None:
"""
Notify the user (non-interactively) of the provided text, by writing it
to the console.
@param text: Some information the user is to be made aware of.
"""
try:
with closing(self.opener()) as f:
f.write(text)
except Exception:
log.failure("Failed to write to console")

View File

@@ -0,0 +1,109 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
import sys
from typing import List, Optional, Union
#
from twisted.conch.ssh.transport import SSHCiphers, SSHClientTransport
from twisted.python import usage
class ConchOptions(usage.Options):
optParameters: List[List[Optional[Union[str, int]]]] = [
["user", "l", None, "Log in using this user name."],
["identity", "i", None],
["ciphers", "c", None],
["macs", "m", None],
["port", "p", None, "Connect to this port. Server must be on the same port."],
["option", "o", None, "Ignored OpenSSH options"],
["host-key-algorithms", "", None],
["known-hosts", "", None, "File to check for host keys"],
["user-authentications", "", None, "Types of user authentications to use."],
["logfile", "", None, "File to log to, or - for stdout"],
]
optFlags = [
["version", "V", "Display version number only."],
["compress", "C", "Enable compression."],
["log", "v", "Enable logging (defaults to stderr)"],
["nox11", "x", "Disable X11 connection forwarding (default)"],
["agent", "A", "Enable authentication agent forwarding"],
["noagent", "a", "Disable authentication agent forwarding (default)"],
["reconnect", "r", "Reconnect to the server if the connection is lost."],
]
compData = usage.Completions(
mutuallyExclusive=[("agent", "noagent")],
optActions={
"user": usage.CompleteUsernames(),
"ciphers": usage.CompleteMultiList(
[v.decode() for v in SSHCiphers.cipherMap.keys()],
descr="ciphers to choose from",
),
"macs": usage.CompleteMultiList(
[v.decode() for v in SSHCiphers.macMap.keys()],
descr="macs to choose from",
),
"host-key-algorithms": usage.CompleteMultiList(
[v.decode() for v in SSHClientTransport.supportedPublicKeys],
descr="host key algorithms to choose from",
),
# "user-authentications": usage.CompleteMultiList(?
# descr='user authentication types' ),
},
extraActions=[
usage.CompleteUserAtHost(),
usage.Completer(descr="command"),
usage.Completer(descr="argument", repeat=True),
],
)
def __init__(self, *args, **kw):
usage.Options.__init__(self, *args, **kw)
self.identitys = []
self.conns = None
def opt_identity(self, i):
"""Identity for public-key authentication"""
self.identitys.append(i)
def opt_ciphers(self, ciphers):
"Select encryption algorithms"
ciphers = ciphers.split(",")
for cipher in ciphers:
if cipher not in SSHCiphers.cipherMap:
sys.exit("Unknown cipher type '%s'" % cipher)
self["ciphers"] = ciphers
def opt_macs(self, macs):
"Specify MAC algorithms"
if isinstance(macs, str):
macs = macs.encode("utf-8")
macs = macs.split(b",")
for mac in macs:
if mac not in SSHCiphers.macMap:
sys.exit("Unknown mac type '%r'" % mac)
self["macs"] = macs
def opt_host_key_algorithms(self, hkas):
"Select host key algorithms"
if isinstance(hkas, str):
hkas = hkas.encode("utf-8")
hkas = hkas.split(b",")
for hka in hkas:
if hka not in SSHClientTransport.supportedPublicKeys:
sys.exit("Unknown host key type '%r'" % hka)
self["host-key-algorithms"] = hkas
def opt_user_authentications(self, uas):
"Choose how to authenticate to the remote server"
if isinstance(uas, str):
uas = uas.encode("utf-8")
self["user-authentications"] = uas.split(b",")
# def opt_compress(self):
# "Enable compression"
# self.enableCompression = 1
# SSHClientTransport.supportedCompressions[0:1] = ['zlib']

View File

@@ -0,0 +1,845 @@
# -*- test-case-name: twisted.conch.test.test_endpoints -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Endpoint implementations of various SSH interactions.
"""
from __future__ import annotations
__all__ = [
"AuthenticationFailed",
"SSHCommandAddress",
"SSHCommandClientEndpoint",
]
import signal
from io import BytesIO
from os.path import expanduser
from struct import unpack
from typing import IO, Any
from zope.interface import Interface, implementer
from twisted.conch.client.agent import SSHAgentClient
from twisted.conch.client.default import _KNOWN_HOSTS
from twisted.conch.client.knownhosts import ConsoleUI, KnownHostsFile
from twisted.conch.ssh.channel import SSHChannel
from twisted.conch.ssh.common import NS, getNS
from twisted.conch.ssh.connection import SSHConnection
from twisted.conch.ssh.keys import Key
from twisted.conch.ssh.transport import SSHClientTransport
from twisted.conch.ssh.userauth import SSHUserAuthClient
from twisted.internet.defer import CancelledError, Deferred, succeed
from twisted.internet.endpoints import TCP4ClientEndpoint, connectProtocol
from twisted.internet.error import ConnectionDone, ProcessTerminated
from twisted.internet.interfaces import IStreamClientEndpoint
from twisted.internet.protocol import Factory
from twisted.logger import Logger
from twisted.python.compat import nativeString, networkString
from twisted.python.failure import Failure
from twisted.python.filepath import FilePath
class AuthenticationFailed(Exception):
"""
An SSH session could not be established because authentication was not
successful.
"""
# This should be public. See #6541.
class _ISSHConnectionCreator(Interface):
"""
An L{_ISSHConnectionCreator} knows how to create SSH connections somehow.
"""
def secureConnection():
"""
Return a new, connected, secured, but not yet authenticated instance of
L{twisted.conch.ssh.transport.SSHServerTransport} or
L{twisted.conch.ssh.transport.SSHClientTransport}.
"""
def cleanupConnection(connection, immediate):
"""
Perform cleanup necessary for a connection object previously returned
from this creator's C{secureConnection} method.
@param connection: An L{twisted.conch.ssh.transport.SSHServerTransport}
or L{twisted.conch.ssh.transport.SSHClientTransport} returned by a
previous call to C{secureConnection}. It is no longer needed by
the caller of that method and may be closed or otherwise cleaned up
as necessary.
@param immediate: If C{True} don't wait for any network communication,
just close the connection immediately and as aggressively as
necessary.
"""
class SSHCommandAddress:
"""
An L{SSHCommandAddress} instance represents the address of an SSH server, a
username which was used to authenticate with that server, and a command
which was run there.
@ivar server: See L{__init__}
@ivar username: See L{__init__}
@ivar command: See L{__init__}
"""
def __init__(self, server, username, command):
"""
@param server: The address of the SSH server on which the command is
running.
@type server: L{IAddress} provider
@param username: An authentication username which was used to
authenticate against the server at the given address.
@type username: L{bytes}
@param command: A command which was run in a session channel on the
server at the given address.
@type command: L{bytes}
"""
self.server = server
self.username = username
self.command = command
class _CommandChannel(SSHChannel):
"""
A L{_CommandChannel} executes a command in a session channel and connects
its input and output to an L{IProtocol} provider.
@ivar _creator: See L{__init__}
@ivar _command: See L{__init__}
@ivar _protocolFactory: See L{__init__}
@ivar _commandConnected: See L{__init__}
@ivar _protocol: An L{IProtocol} provider created using C{_protocolFactory}
which is hooked up to the running command's input and output streams.
"""
name = b"session"
_log = Logger()
def __init__(self, creator, command, protocolFactory, commandConnected):
"""
@param creator: The L{_ISSHConnectionCreator} provider which was used
to get the connection which this channel exists on.
@type creator: L{_ISSHConnectionCreator} provider
@param command: The command to be executed.
@type command: L{bytes}
@param protocolFactory: A client factory to use to build a L{IProtocol}
provider to use to associate with the running command.
@param commandConnected: A L{Deferred} to use to signal that execution
of the command has failed or that it has succeeded and the command
is now running.
@type commandConnected: L{Deferred}
"""
SSHChannel.__init__(self)
self._creator = creator
self._command = command
self._protocolFactory = protocolFactory
self._commandConnected = commandConnected
self._reason = None
def openFailed(self, reason):
"""
When the request to open a new channel to run this command in fails,
fire the C{commandConnected} deferred with a failure indicating that.
"""
self._commandConnected.errback(reason)
def channelOpen(self, ignored):
"""
When the request to open a new channel to run this command in succeeds,
issue an C{"exec"} request to run the command.
"""
command = self.conn.sendRequest(
self, b"exec", NS(self._command), wantReply=True
)
command.addCallbacks(self._execSuccess, self._execFailure)
def _execFailure(self, reason):
"""
When the request to execute the command in this channel fails, fire the
C{commandConnected} deferred with a failure indicating this.
@param reason: The cause of the command execution failure.
@type reason: L{Failure}
"""
self._commandConnected.errback(reason)
def _execSuccess(self, ignored):
"""
When the request to execute the command in this channel succeeds, use
C{protocolFactory} to build a protocol to handle the command's input
and output and connect the protocol to a transport representing those
streams.
Also fire C{commandConnected} with the created protocol after it is
connected to its transport.
@param ignored: The (ignored) result of the execute request
"""
self._protocol = self._protocolFactory.buildProtocol(
SSHCommandAddress(
self.conn.transport.transport.getPeer(),
self.conn.transport.creator.username,
self.conn.transport.creator.command,
)
)
self._protocol.makeConnection(self)
self._commandConnected.callback(self._protocol)
def dataReceived(self, data):
"""
When the command's stdout data arrives over the channel, deliver it to
the protocol instance.
@param data: The bytes from the command's stdout.
@type data: L{bytes}
"""
self._protocol.dataReceived(data)
def request_exit_status(self, data):
"""
When the server sends the command's exit status, record it for later
delivery to the protocol.
@param data: The network-order four byte representation of the exit
status of the command.
@type data: L{bytes}
"""
(status,) = unpack(">L", data)
if status != 0:
self._reason = ProcessTerminated(status, None, None)
def request_exit_signal(self, data):
"""
When the server sends the command's exit status, record it for later
delivery to the protocol.
@param data: The network-order four byte representation of the exit
signal of the command.
@type data: L{bytes}
"""
shortSignalName, data = getNS(data)
coreDumped, data = bool(ord(data[0:1])), data[1:]
errorMessage, data = getNS(data)
languageTag, data = getNS(data)
signalName = f"SIG{nativeString(shortSignalName)}"
signalID = getattr(signal, signalName, -1)
self._log.info(
"Process exited with signal {shortSignalName!r};"
" core dumped: {coreDumped};"
" error message: {errorMessage};"
" language: {languageTag!r}",
shortSignalName=shortSignalName,
coreDumped=coreDumped,
errorMessage=errorMessage.decode("utf-8"),
languageTag=languageTag,
)
self._reason = ProcessTerminated(None, signalID, None)
def closed(self):
"""
When the channel closes, deliver disconnection notification to the
protocol.
"""
self._creator.cleanupConnection(self.conn, False)
if self._reason is None:
reason = ConnectionDone("ssh channel closed")
else:
reason = self._reason
self._protocol.connectionLost(Failure(reason))
class _ConnectionReady(SSHConnection):
"""
L{_ConnectionReady} is an L{SSHConnection} (an SSH service) which only
propagates the I{serviceStarted} event to a L{Deferred} to be handled
elsewhere.
"""
def __init__(self, ready):
"""
@param ready: A L{Deferred} which should be fired when
I{serviceStarted} happens.
"""
SSHConnection.__init__(self)
self._ready = ready
def serviceStarted(self):
"""
When the SSH I{connection} I{service} this object represents is ready
to be used, fire the C{connectionReady} L{Deferred} to publish that
event to some other interested party.
"""
self._ready.callback(self)
del self._ready
class _UserAuth(SSHUserAuthClient):
"""
L{_UserAuth} implements the client part of SSH user authentication in the
convenient way a user might expect if they are familiar with the
interactive I{ssh} command line client.
L{_UserAuth} supports key-based authentication, password-based
authentication, and delegating authentication to an agent.
"""
password = None
keys = None
agent = None
def getPublicKey(self):
"""
Retrieve the next public key object to offer to the server, possibly
delegating to an authentication agent if there is one.
@return: The public part of a key pair that could be used to
authenticate with the server, or L{None} if there are no more
public keys to try.
@rtype: L{twisted.conch.ssh.keys.Key} or L{None}
"""
if self.agent is not None:
return self.agent.getPublicKey()
if self.keys:
self.key = self.keys.pop(0)
else:
self.key = None
return self.key.public()
def signData(self, publicKey, signData):
"""
Extend the base signing behavior by using an SSH agent to sign the
data, if one is available.
@type publicKey: L{Key}
@type signData: L{str}
"""
if self.agent is not None:
return self.agent.signData(publicKey.blob(), signData)
else:
return SSHUserAuthClient.signData(self, publicKey, signData)
def getPrivateKey(self):
"""
Get the private part of a key pair to use for authentication. The key
corresponds to the public part most recently returned from
C{getPublicKey}.
@return: A L{Deferred} which fires with the private key.
@rtype: L{Deferred}
"""
return succeed(self.key)
def getPassword(self):
"""
Get the password to use for authentication.
@return: A L{Deferred} which fires with the password, or L{None} if the
password was not specified.
"""
if self.password is None:
return
return succeed(self.password)
def ssh_USERAUTH_SUCCESS(self, packet):
"""
Handle user authentication success in the normal way, but also make a
note of the state change on the L{_CommandTransport}.
"""
self.transport._state = b"CHANNELLING"
return SSHUserAuthClient.ssh_USERAUTH_SUCCESS(self, packet)
def connectToAgent(self, endpoint):
"""
Set up a connection to the authentication agent and trigger its
initialization.
@param endpoint: An endpoint which can be used to connect to the
authentication agent.
@type endpoint: L{IStreamClientEndpoint} provider
@return: A L{Deferred} which fires when the agent connection is ready
for use.
"""
factory = Factory()
factory.protocol = SSHAgentClient
d = endpoint.connect(factory)
def connected(agent):
self.agent = agent
return agent.getPublicKeys()
d.addCallback(connected)
return d
def loseAgentConnection(self):
"""
Disconnect the agent.
"""
if self.agent is None:
return
self.agent.transport.loseConnection()
class _CommandTransport(SSHClientTransport):
"""
L{_CommandTransport} is an SSH client I{transport} which includes a host
key verification step before it will proceed to secure the connection.
L{_CommandTransport} also knows how to set up a connection to an
authentication agent if it is told where it can connect to one.
@ivar _userauth: The L{_UserAuth} instance which is in charge of the
overall authentication process or L{None} if the SSH connection has not
reach yet the C{user-auth} service.
@type _userauth: L{_UserAuth}
"""
# STARTING -> SECURING -> AUTHENTICATING -> CHANNELLING -> RUNNING
_state = b"STARTING"
_hostKeyFailure = None
_userauth = None
def __init__(self, creator):
"""
@param creator: The L{_NewConnectionHelper} that created this
connection.
@type creator: L{_NewConnectionHelper}.
"""
self.connectionReady = Deferred(lambda d: self.transport.abortConnection())
# Clear the reference to that deferred to help the garbage collector
# and to signal to other parts of this implementation (in particular
# connectionLost) that it has already been fired and does not need to
# be fired again.
def readyFired(result):
self.connectionReady = None
return result
self.connectionReady.addBoth(readyFired)
self.creator = creator
def verifyHostKey(self, hostKey, fingerprint):
"""
Ask the L{KnownHostsFile} provider available on the factory which
created this protocol this protocol to verify the given host key.
@return: A L{Deferred} which fires with the result of
L{KnownHostsFile.verifyHostKey}.
"""
hostname = self.creator.hostname
ip = networkString(self.transport.getPeer().host)
self._state = b"SECURING"
d = self.creator.knownHosts.verifyHostKey(
self.creator.ui, hostname, ip, Key.fromString(hostKey)
)
d.addErrback(self._saveHostKeyFailure)
return d
def _saveHostKeyFailure(self, reason):
"""
When host key verification fails, record the reason for the failure in
order to fire a L{Deferred} with it later.
@param reason: The cause of the host key verification failure.
@type reason: L{Failure}
@return: C{reason}
@rtype: L{Failure}
"""
self._hostKeyFailure = reason
return reason
def connectionSecure(self):
"""
When the connection is secure, start the authentication process.
"""
self._state = b"AUTHENTICATING"
command = _ConnectionReady(self.connectionReady)
self._userauth = _UserAuth(self.creator.username, command)
self._userauth.password = self.creator.password
if self.creator.keys:
self._userauth.keys = list(self.creator.keys)
if self.creator.agentEndpoint is not None:
d = self._userauth.connectToAgent(self.creator.agentEndpoint)
else:
d = succeed(None)
def maybeGotAgent(ignored):
self.requestService(self._userauth)
d.addBoth(maybeGotAgent)
def connectionLost(self, reason):
"""
When the underlying connection to the SSH server is lost, if there were
any connection setup errors, propagate them. Also, clean up the
connection to the ssh agent if one was created.
"""
if self._userauth:
self._userauth.loseAgentConnection()
if self._state == b"RUNNING" or self.connectionReady is None:
return
if self._state == b"SECURING" and self._hostKeyFailure is not None:
reason = self._hostKeyFailure
elif self._state == b"AUTHENTICATING":
reason = Failure(
AuthenticationFailed("Connection lost while authenticating")
)
self.connectionReady.errback(reason)
@implementer(IStreamClientEndpoint)
class SSHCommandClientEndpoint:
"""
L{SSHCommandClientEndpoint} exposes the command-executing functionality of
SSH servers.
L{SSHCommandClientEndpoint} can set up a new SSH connection, authenticate
it in any one of a number of different ways (keys, passwords, agents),
launch a command over that connection and then associate its input and
output with a protocol.
It can also re-use an existing, already-authenticated SSH connection
(perhaps one which already has some SSH channels being used for other
purposes). In this case it creates a new SSH channel to use to execute the
command. Notably this means it supports multiplexing several different
command invocations over a single SSH connection.
"""
def __init__(self, creator, command):
"""
@param creator: An L{_ISSHConnectionCreator} provider which will be
used to set up the SSH connection which will be used to run a
command.
@type creator: L{_ISSHConnectionCreator} provider
@param command: The command line to execute on the SSH server. This
byte string is interpreted by a shell on the SSH server, so it may
have a value like C{"ls /"}. Take care when trying to run a
command like C{"/Volumes/My Stuff/a-program"} - spaces (and other
special bytes) may require escaping.
@type command: L{bytes}
"""
self._creator = creator
self._command = command
@classmethod
def newConnection(
cls,
reactor,
command,
username,
hostname,
port=None,
keys=None,
password=None,
agentEndpoint=None,
knownHosts=None,
ui=None,
):
"""
Create and return a new endpoint which will try to create a new
connection to an SSH server and run a command over it. It will also
close the connection if there are problems leading up to the command
being executed, after the command finishes, or if the connection
L{Deferred} is cancelled.
@param reactor: The reactor to use to establish the connection.
@type reactor: L{IReactorTCP} provider
@param command: See L{__init__}'s C{command} argument.
@param username: The username with which to authenticate to the SSH
server.
@type username: L{bytes}
@param hostname: The hostname of the SSH server.
@type hostname: L{bytes}
@param port: The port number of the SSH server. By default, the
standard SSH port number is used.
@type port: L{int}
@param keys: Private keys with which to authenticate to the SSH server,
if key authentication is to be attempted (otherwise L{None}).
@type keys: L{list} of L{Key}
@param password: The password with which to authenticate to the SSH
server, if password authentication is to be attempted (otherwise
L{None}).
@type password: L{bytes} or L{None}
@param agentEndpoint: An L{IStreamClientEndpoint} provider which may be
used to connect to an SSH agent, if one is to be used to help with
authentication.
@type agentEndpoint: L{IStreamClientEndpoint} provider
@param knownHosts: The currently known host keys, used to check the
host key presented by the server we actually connect to.
@type knownHosts: L{KnownHostsFile}
@param ui: An object for interacting with users to make decisions about
whether to accept the server host keys. If L{None}, a L{ConsoleUI}
connected to /dev/tty will be used; if /dev/tty is unavailable, an
object which answers C{b"no"} to all prompts will be used.
@type ui: L{None} or L{ConsoleUI}
@return: A new instance of C{cls} (probably
L{SSHCommandClientEndpoint}).
"""
helper = _NewConnectionHelper(
reactor,
hostname,
port,
command,
username,
keys,
password,
agentEndpoint,
knownHosts,
ui,
)
return cls(helper, command)
@classmethod
def existingConnection(cls, connection, command):
"""
Create and return a new endpoint which will try to open a new channel
on an existing SSH connection and run a command over it. It will
B{not} close the connection if there is a problem executing the command
or after the command finishes.
@param connection: An existing connection to an SSH server.
@type connection: L{SSHConnection}
@param command: See L{SSHCommandClientEndpoint.newConnection}'s
C{command} parameter.
@type command: L{bytes}
@return: A new instance of C{cls} (probably
L{SSHCommandClientEndpoint}).
"""
helper = _ExistingConnectionHelper(connection)
return cls(helper, command)
def connect(self, protocolFactory):
"""
Set up an SSH connection, use a channel from that connection to launch
a command, and hook the stdin and stdout of that command up as a
transport for a protocol created by the given factory.
@param protocolFactory: A L{Factory} to use to create the protocol
which will be connected to the stdin and stdout of the command on
the SSH server.
@return: A L{Deferred} which will fire with an error if the connection
cannot be set up for any reason or with the protocol instance
created by C{protocolFactory} once it has been connected to the
command.
"""
d = self._creator.secureConnection()
d.addCallback(self._executeCommand, protocolFactory)
return d
def _executeCommand(self, connection, protocolFactory):
"""
Given a secured SSH connection, try to execute a command in a new
channel created on it and associate the result with a protocol from the
given factory.
@param connection: See L{SSHCommandClientEndpoint.existingConnection}'s
C{connection} parameter.
@param protocolFactory: See L{SSHCommandClientEndpoint.connect}'s
C{protocolFactory} parameter.
@return: See L{SSHCommandClientEndpoint.connect}'s return value.
"""
commandConnected = Deferred()
def disconnectOnFailure(passthrough):
# Close the connection immediately in case of cancellation, since
# that implies user wants it gone immediately (e.g. a timeout):
immediate = passthrough.check(CancelledError)
self._creator.cleanupConnection(connection, immediate)
return passthrough
commandConnected.addErrback(disconnectOnFailure)
channel = _CommandChannel(
self._creator, self._command, protocolFactory, commandConnected
)
connection.openChannel(channel)
return commandConnected
@implementer(_ISSHConnectionCreator)
class _NewConnectionHelper:
"""
L{_NewConnectionHelper} implements L{_ISSHConnectionCreator} by
establishing a brand new SSH connection, securing it, and authenticating.
"""
_KNOWN_HOSTS = _KNOWN_HOSTS
port = 22
def __init__(
self,
reactor: Any,
hostname: str,
port: int,
command: str,
username: str,
keys: str,
password: str,
agentEndpoint: str,
knownHosts: str | None,
ui: ConsoleUI | None,
tty: FilePath[bytes] | FilePath[str] = FilePath(b"/dev/tty"),
):
"""
@param tty: The path of the tty device to use in case C{ui} is L{None}.
@type tty: L{FilePath}
@see: L{SSHCommandClientEndpoint.newConnection}
"""
self.reactor = reactor
self.hostname = hostname
if port is not None:
self.port = port
self.command = command
self.username = username
self.keys = keys
self.password = password
self.agentEndpoint = agentEndpoint
if knownHosts is None:
knownHosts = self._knownHosts()
self.knownHosts = knownHosts
if ui is None:
ui = ConsoleUI(self._opener)
self.ui = ui
self.tty: FilePath[bytes] | FilePath[str] = tty
def _opener(self) -> IO[bytes]:
"""
Open the tty if possible, otherwise give back a file-like object from
which C{b"no"} can be read.
For use as the opener argument to L{ConsoleUI}.
"""
try:
return self.tty.open("r+")
except BaseException:
# Give back a file-like object from which can be read a byte string
# that KnownHostsFile recognizes as rejecting some option (b"no").
return BytesIO(b"no")
@classmethod
def _knownHosts(cls):
"""
@return: A L{KnownHostsFile} instance pointed at the user's personal
I{known hosts} file.
@rtype: L{KnownHostsFile}
"""
return KnownHostsFile.fromPath(FilePath(expanduser(cls._KNOWN_HOSTS)))
def secureConnection(self):
"""
Create and return a new SSH connection which has been secured and on
which authentication has already happened.
@return: A L{Deferred} which fires with the ready-to-use connection or
with a failure if something prevents the connection from being
setup, secured, or authenticated.
"""
protocol = _CommandTransport(self)
ready = protocol.connectionReady
sshClient = TCP4ClientEndpoint(
self.reactor, nativeString(self.hostname), self.port
)
d = connectProtocol(sshClient, protocol)
d.addCallback(lambda ignored: ready)
return d
def cleanupConnection(self, connection, immediate):
"""
Clean up the connection by closing it. The command running on the
endpoint has ended so the connection is no longer needed.
@param connection: The L{SSHConnection} to close.
@type connection: L{SSHConnection}
@param immediate: Whether to close connection immediately.
@type immediate: L{bool}.
"""
if immediate:
# We're assuming the underlying connection is an ITCPTransport,
# which is what the current implementation is restricted to:
connection.transport.transport.abortConnection()
else:
connection.transport.loseConnection()
@implementer(_ISSHConnectionCreator)
class _ExistingConnectionHelper:
"""
L{_ExistingConnectionHelper} implements L{_ISSHConnectionCreator} by
handing out an existing SSH connection which is supplied to its
initializer.
"""
def __init__(self, connection):
"""
@param connection: See L{SSHCommandClientEndpoint.existingConnection}'s
C{connection} parameter.
"""
self.connection = connection
def secureConnection(self):
"""
@return: A L{Deferred} that fires synchronously with the
already-established connection object.
"""
return succeed(self.connection)
def cleanupConnection(self, connection, immediate):
"""
Do not do any cleanup on the connection. Leave that responsibility to
whatever code created it in the first place.
@param connection: The L{SSHConnection} which will not be modified in
any way.
@type connection: L{SSHConnection}
@param immediate: An argument which will be ignored.
@type immediate: L{bool}.
"""

View File

@@ -0,0 +1,96 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
An error to represent bad things happening in Conch.
Maintainer: Paul Swartz
"""
from twisted.cred.error import UnauthorizedLogin
class ConchError(Exception):
def __init__(self, value, data=None):
Exception.__init__(self, value, data)
self.value = value
self.data = data
class NotEnoughAuthentication(Exception):
"""
This is thrown if the authentication is valid, but is not enough to
successfully verify the user. i.e. don't retry this type of
authentication, try another one.
"""
class ValidPublicKey(UnauthorizedLogin):
"""
Raised by public key checkers when they receive public key credentials
that don't contain a signature at all, but are valid in every other way.
(e.g. the public key matches one in the user's authorized_keys file).
Protocol code (eg
L{SSHUserAuthServer<twisted.conch.ssh.userauth.SSHUserAuthServer>}) which
attempts to log in using
L{ISSHPrivateKey<twisted.cred.credentials.ISSHPrivateKey>} credentials
should be prepared to handle a failure of this type by telling the user to
re-authenticate using the same key and to include a signature with the new
attempt.
See U{http://www.ietf.org/rfc/rfc4252.txt} section 7 for more details.
"""
class IgnoreAuthentication(Exception):
"""
This is thrown to let the UserAuthServer know it doesn't need to handle the
authentication anymore.
"""
class MissingKeyStoreError(Exception):
"""
Raised if an SSHAgentServer starts receiving data without its factory
providing a keys dict on which to read/write key data.
"""
class UserRejectedKey(Exception):
"""
The user interactively rejected a key.
"""
class InvalidEntry(Exception):
"""
An entry in a known_hosts file could not be interpreted as a valid entry.
"""
class HostKeyChanged(Exception):
"""
The host key of a remote host has changed.
@ivar offendingEntry: The entry which contains the persistent host key that
disagrees with the given host key.
@type offendingEntry: L{twisted.conch.interfaces.IKnownHostEntry}
@ivar path: a reference to the known_hosts file that the offending entry
was loaded from
@type path: L{twisted.python.filepath.FilePath}
@ivar lineno: The line number of the offending entry in the given path.
@type lineno: L{int}
"""
def __init__(self, offendingEntry, path, lineno):
Exception.__init__(self)
self.offendingEntry = offendingEntry
self.path = path
self.lineno = lineno

View File

@@ -0,0 +1,4 @@
"""
Insults: a replacement for Curses/S-Lang.
Very basic at the moment."""

View File

@@ -0,0 +1,556 @@
# -*- test-case-name: twisted.conch.test.test_helper -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Partial in-memory terminal emulator
@author: Jp Calderone
"""
import re
import string
from zope.interface import implementer
from incremental import Version
from twisted.conch.insults import insults
from twisted.internet import defer, protocol, reactor
from twisted.logger import Logger
from twisted.python import _textattributes
from twisted.python.compat import iterbytes
from twisted.python.deprecate import deprecated, deprecatedModuleAttribute
FOREGROUND = 30
BACKGROUND = 40
BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, N_COLORS = range(9)
class _FormattingState(_textattributes._FormattingStateMixin):
"""
Represents the formatting state/attributes of a single character.
Character set, intensity, underlinedness, blinkitude, video
reversal, as well as foreground and background colors made up a
character's attributes.
"""
compareAttributes = (
"charset",
"bold",
"underline",
"blink",
"reverseVideo",
"foreground",
"background",
"_subtracting",
)
def __init__(
self,
charset=insults.G0,
bold=False,
underline=False,
blink=False,
reverseVideo=False,
foreground=WHITE,
background=BLACK,
_subtracting=False,
):
self.charset = charset
self.bold = bold
self.underline = underline
self.blink = blink
self.reverseVideo = reverseVideo
self.foreground = foreground
self.background = background
self._subtracting = _subtracting
@deprecated(Version("Twisted", 13, 1, 0))
def wantOne(self, **kw):
"""
Add a character attribute to a copy of this formatting state.
@param kw: An optional attribute name and value can be provided with
a keyword argument.
@return: A formatting state instance with the new attribute.
@see: L{DefaultFormattingState._withAttribute}.
"""
k, v = kw.popitem()
return self._withAttribute(k, v)
def toVT102(self):
# Spit out a vt102 control sequence that will set up
# all the attributes set here. Except charset.
attrs = []
if self._subtracting:
attrs.append(0)
if self.bold:
attrs.append(insults.BOLD)
if self.underline:
attrs.append(insults.UNDERLINE)
if self.blink:
attrs.append(insults.BLINK)
if self.reverseVideo:
attrs.append(insults.REVERSE_VIDEO)
if self.foreground != WHITE:
attrs.append(FOREGROUND + self.foreground)
if self.background != BLACK:
attrs.append(BACKGROUND + self.background)
if attrs:
return "\x1b[" + ";".join(map(str, attrs)) + "m"
return ""
CharacterAttribute = _FormattingState
deprecatedModuleAttribute(
Version("Twisted", 13, 1, 0),
"Use twisted.conch.insults.text.assembleFormattedText instead.",
"twisted.conch.insults.helper",
"CharacterAttribute",
)
# XXX - need to support scroll regions and scroll history
@implementer(insults.ITerminalTransport)
class TerminalBuffer(protocol.Protocol):
"""
An in-memory terminal emulator.
"""
for keyID in (
b"UP_ARROW",
b"DOWN_ARROW",
b"RIGHT_ARROW",
b"LEFT_ARROW",
b"HOME",
b"INSERT",
b"DELETE",
b"END",
b"PGUP",
b"PGDN",
b"F1",
b"F2",
b"F3",
b"F4",
b"F5",
b"F6",
b"F7",
b"F8",
b"F9",
b"F10",
b"F11",
b"F12",
):
execBytes = keyID + b" = object()"
execStr = execBytes.decode("ascii")
exec(execStr)
TAB = b"\t"
BACKSPACE = b"\x7f"
width = 80
height = 24
fill = b" "
void = object()
_log = Logger()
def getCharacter(self, x, y):
return self.lines[y][x]
def connectionMade(self):
self.reset()
def write(self, data):
"""
Add the given printable bytes to the terminal.
Line feeds in L{bytes} will be replaced with carriage return / line
feed pairs.
"""
for b in iterbytes(data.replace(b"\n", b"\r\n")):
self.insertAtCursor(b)
def _currentFormattingState(self):
return _FormattingState(self.activeCharset, **self.graphicRendition)
def insertAtCursor(self, b):
"""
Add one byte to the terminal at the cursor and make consequent state
updates.
If b is a carriage return, move the cursor to the beginning of the
current row.
If b is a line feed, move the cursor to the next row or scroll down if
the cursor is already in the last row.
Otherwise, if b is printable, put it at the cursor position (inserting
or overwriting as dictated by the current mode) and move the cursor.
"""
if b == b"\r":
self.x = 0
elif b == b"\n":
self._scrollDown()
elif b in string.printable.encode("ascii"):
if self.x >= self.width:
self.nextLine()
ch = (b, self._currentFormattingState())
if self.modes.get(insults.modes.IRM):
self.lines[self.y][self.x : self.x] = [ch]
self.lines[self.y].pop()
else:
self.lines[self.y][self.x] = ch
self.x += 1
def _emptyLine(self, width):
return [(self.void, self._currentFormattingState()) for i in range(width)]
def _scrollDown(self):
self.y += 1
if self.y >= self.height:
self.y -= 1
del self.lines[0]
self.lines.append(self._emptyLine(self.width))
def _scrollUp(self):
self.y -= 1
if self.y < 0:
self.y = 0
del self.lines[-1]
self.lines.insert(0, self._emptyLine(self.width))
def cursorUp(self, n=1):
self.y = max(0, self.y - n)
def cursorDown(self, n=1):
self.y = min(self.height - 1, self.y + n)
def cursorBackward(self, n=1):
self.x = max(0, self.x - n)
def cursorForward(self, n=1):
self.x = min(self.width, self.x + n)
def cursorPosition(self, column, line):
self.x = column
self.y = line
def cursorHome(self):
self.x = self.home.x
self.y = self.home.y
def index(self):
self._scrollDown()
def reverseIndex(self):
self._scrollUp()
def nextLine(self):
"""
Update the cursor position attributes and scroll down if appropriate.
"""
self.x = 0
self._scrollDown()
def saveCursor(self):
self._savedCursor = (self.x, self.y)
def restoreCursor(self):
self.x, self.y = self._savedCursor
del self._savedCursor
def setModes(self, modes):
for m in modes:
self.modes[m] = True
def resetModes(self, modes):
for m in modes:
try:
del self.modes[m]
except KeyError:
pass
def setPrivateModes(self, modes):
"""
Enable the given modes.
Track which modes have been enabled so that the implementations of
other L{insults.ITerminalTransport} methods can be properly implemented
to respect these settings.
@see: L{resetPrivateModes}
@see: L{insults.ITerminalTransport.setPrivateModes}
"""
for m in modes:
self.privateModes[m] = True
def resetPrivateModes(self, modes):
"""
Disable the given modes.
@see: L{setPrivateModes}
@see: L{insults.ITerminalTransport.resetPrivateModes}
"""
for m in modes:
try:
del self.privateModes[m]
except KeyError:
pass
def applicationKeypadMode(self):
self.keypadMode = "app"
def numericKeypadMode(self):
self.keypadMode = "num"
def selectCharacterSet(self, charSet, which):
self.charsets[which] = charSet
def shiftIn(self):
self.activeCharset = insults.G0
def shiftOut(self):
self.activeCharset = insults.G1
def singleShift2(self):
oldActiveCharset = self.activeCharset
self.activeCharset = insults.G2
f = self.insertAtCursor
def insertAtCursor(b):
f(b)
del self.insertAtCursor
self.activeCharset = oldActiveCharset
self.insertAtCursor = insertAtCursor
def singleShift3(self):
oldActiveCharset = self.activeCharset
self.activeCharset = insults.G3
f = self.insertAtCursor
def insertAtCursor(b):
f(b)
del self.insertAtCursor
self.activeCharset = oldActiveCharset
self.insertAtCursor = insertAtCursor
def selectGraphicRendition(self, *attributes):
for a in attributes:
if a == insults.NORMAL:
self.graphicRendition = {
"bold": False,
"underline": False,
"blink": False,
"reverseVideo": False,
"foreground": WHITE,
"background": BLACK,
}
elif a == insults.BOLD:
self.graphicRendition["bold"] = True
elif a == insults.UNDERLINE:
self.graphicRendition["underline"] = True
elif a == insults.BLINK:
self.graphicRendition["blink"] = True
elif a == insults.REVERSE_VIDEO:
self.graphicRendition["reverseVideo"] = True
else:
try:
v = int(a)
except ValueError:
self._log.error(
"Unknown graphic rendition attribute: {attr!r}", attr=a
)
else:
if FOREGROUND <= v <= FOREGROUND + N_COLORS:
self.graphicRendition["foreground"] = v - FOREGROUND
elif BACKGROUND <= v <= BACKGROUND + N_COLORS:
self.graphicRendition["background"] = v - BACKGROUND
else:
self._log.error(
"Unknown graphic rendition attribute: {attr!r}", attr=a
)
def eraseLine(self):
self.lines[self.y] = self._emptyLine(self.width)
def eraseToLineEnd(self):
width = self.width - self.x
self.lines[self.y][self.x :] = self._emptyLine(width)
def eraseToLineBeginning(self):
self.lines[self.y][: self.x + 1] = self._emptyLine(self.x + 1)
def eraseDisplay(self):
self.lines = [self._emptyLine(self.width) for i in range(self.height)]
def eraseToDisplayEnd(self):
self.eraseToLineEnd()
height = self.height - self.y - 1
self.lines[self.y + 1 :] = [self._emptyLine(self.width) for i in range(height)]
def eraseToDisplayBeginning(self):
self.eraseToLineBeginning()
self.lines[: self.y] = [self._emptyLine(self.width) for i in range(self.y)]
def deleteCharacter(self, n=1):
del self.lines[self.y][self.x : self.x + n]
self.lines[self.y].extend(self._emptyLine(min(self.width - self.x, n)))
def insertLine(self, n=1):
self.lines[self.y : self.y] = [self._emptyLine(self.width) for i in range(n)]
del self.lines[self.height :]
def deleteLine(self, n=1):
del self.lines[self.y : self.y + n]
self.lines.extend([self._emptyLine(self.width) for i in range(n)])
def reportCursorPosition(self):
return (self.x, self.y)
def reset(self):
self.home = insults.Vector(0, 0)
self.x = self.y = 0
self.modes = {}
self.privateModes = {}
self.setPrivateModes(
[insults.privateModes.AUTO_WRAP, insults.privateModes.CURSOR_MODE]
)
self.numericKeypad = "app"
self.activeCharset = insults.G0
self.graphicRendition = {
"bold": False,
"underline": False,
"blink": False,
"reverseVideo": False,
"foreground": WHITE,
"background": BLACK,
}
self.charsets = {
insults.G0: insults.CS_US,
insults.G1: insults.CS_US,
insults.G2: insults.CS_ALTERNATE,
insults.G3: insults.CS_ALTERNATE_SPECIAL,
}
self.eraseDisplay()
def unhandledControlSequence(self, buf):
print("Could not handle", repr(buf))
def __bytes__(self):
lines = []
for L in self.lines:
buf = []
length = 0
for ch, attr in L:
if ch is not self.void:
buf.append(ch)
length = len(buf)
else:
buf.append(self.fill)
lines.append(b"".join(buf[:length]))
return b"\n".join(lines)
def getHost(self):
# ITransport.getHost
raise NotImplementedError("Unimplemented: TerminalBuffer.getHost")
def getPeer(self):
# ITransport.getPeer
raise NotImplementedError("Unimplemented: TerminalBuffer.getPeer")
def loseConnection(self):
# ITransport.loseConnection
raise NotImplementedError("Unimplemented: TerminalBuffer.loseConnection")
def writeSequence(self, data):
# ITransport.writeSequence
raise NotImplementedError("Unimplemented: TerminalBuffer.writeSequence")
def horizontalTabulationSet(self):
# ITerminalTransport.horizontalTabulationSet
raise NotImplementedError(
"Unimplemented: TerminalBuffer.horizontalTabulationSet"
)
def tabulationClear(self):
# TerminalTransport.tabulationClear
raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClear")
def tabulationClearAll(self):
# TerminalTransport.tabulationClearAll
raise NotImplementedError("Unimplemented: TerminalBuffer.tabulationClearAll")
def doubleHeightLine(self, top=True):
# ITerminalTransport.doubleHeightLine
raise NotImplementedError("Unimplemented: TerminalBuffer.doubleHeightLine")
def singleWidthLine(self):
# ITerminalTransport.singleWidthLine
raise NotImplementedError("Unimplemented: TerminalBuffer.singleWidthLine")
def doubleWidthLine(self):
# ITerminalTransport.doubleWidthLine
raise NotImplementedError("Unimplemented: TerminalBuffer.doubleWidthLine")
class ExpectationTimeout(Exception):
pass
class ExpectableBuffer(TerminalBuffer):
_mark = 0
def connectionMade(self):
TerminalBuffer.connectionMade(self)
self._expecting = []
def write(self, data):
TerminalBuffer.write(self, data)
self._checkExpected()
def cursorHome(self):
TerminalBuffer.cursorHome(self)
self._mark = 0
def _timeoutExpected(self, d):
d.errback(ExpectationTimeout())
self._checkExpected()
def _checkExpected(self):
s = self.__bytes__()[self._mark :]
while self._expecting:
expr, timer, deferred = self._expecting[0]
if timer and not timer.active():
del self._expecting[0]
continue
for match in expr.finditer(s):
if timer:
timer.cancel()
del self._expecting[0]
self._mark += match.end()
s = s[match.end() :]
deferred.callback(match)
break
else:
return
def expect(self, expression, timeout=None, scheduler=reactor):
d = defer.Deferred()
timer = None
if timeout:
timer = scheduler.callLater(timeout, self._timeoutExpected, d)
self._expecting.append((re.compile(expression), timer, d))
self._checkExpected()
return d
__all__ = ["CharacterAttribute", "TerminalBuffer", "ExpectableBuffer"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
# -*- test-case-name: twisted.conch.test.test_text -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Character attribute manipulation API.
This module provides a domain-specific language (using Python syntax)
for the creation of text with additional display attributes associated
with it. It is intended as an alternative to manually building up
strings containing ECMA 48 character attribute control codes. It
currently supports foreground and background colors (black, red,
green, yellow, blue, magenta, cyan, and white), intensity selection,
underlining, blinking and reverse video. Character set selection
support is planned.
Character attributes are specified by using two Python operations:
attribute lookup and indexing. For example, the string \"Hello
world\" with red foreground and all other attributes set to their
defaults, assuming the name twisted.conch.insults.text.attributes has
been imported and bound to the name \"A\" (with the statement C{from
twisted.conch.insults.text import attributes as A}, for example) one
uses this expression::
A.fg.red[\"Hello world\"]
Other foreground colors are set by substituting their name for
\"red\". To set both a foreground and a background color, this
expression is used::
A.fg.red[A.bg.green[\"Hello world\"]]
Note that either A.bg.green can be nested within A.fg.red or vice
versa. Also note that multiple items can be nested within a single
index operation by separating them with commas::
A.bg.green[A.fg.red[\"Hello\"], " ", A.fg.blue[\"world\"]]
Other character attributes are set in a similar fashion. To specify a
blinking version of the previous expression::
A.blink[A.bg.green[A.fg.red[\"Hello\"], " ", A.fg.blue[\"world\"]]]
C{A.reverseVideo}, C{A.underline}, and C{A.bold} are also valid.
A third operation is actually supported: unary negation. This turns
off an attribute when an enclosing expression would otherwise have
caused it to be on. For example::
A.underline[A.fg.red[\"Hello\", -A.underline[\" world\"]]]
A formatting structure can then be serialized into a string containing the
necessary VT102 control codes with L{assembleFormattedText}.
@see: L{twisted.conch.insults.text._CharacterAttributes}
@author: Jp Calderone
"""
from incremental import Version
from twisted.conch.insults import helper, insults
from twisted.python import _textattributes
from twisted.python.deprecate import deprecatedModuleAttribute
flatten = _textattributes.flatten
deprecatedModuleAttribute(
Version("Twisted", 13, 1, 0),
"Use twisted.conch.insults.text.assembleFormattedText instead.",
"twisted.conch.insults.text",
"flatten",
)
_TEXT_COLORS = {
"black": helper.BLACK,
"red": helper.RED,
"green": helper.GREEN,
"yellow": helper.YELLOW,
"blue": helper.BLUE,
"magenta": helper.MAGENTA,
"cyan": helper.CYAN,
"white": helper.WHITE,
}
class _CharacterAttributes(_textattributes.CharacterAttributesMixin):
"""
Factory for character attributes, including foreground and background color
and non-color attributes such as bold, reverse video and underline.
Character attributes are applied to actual text by using object
indexing-syntax (C{obj['abc']}) after accessing a factory attribute, for
example::
attributes.bold['Some text']
These can be nested to mix attributes::
attributes.bold[attributes.underline['Some text']]
And multiple values can be passed::
attributes.normal[attributes.bold['Some'], ' text']
Non-color attributes can be accessed by attribute name, available
attributes are:
- bold
- blink
- reverseVideo
- underline
Available colors are:
0. black
1. red
2. green
3. yellow
4. blue
5. magenta
6. cyan
7. white
@ivar fg: Foreground colors accessed by attribute name, see above
for possible names.
@ivar bg: Background colors accessed by attribute name, see above
for possible names.
"""
fg = _textattributes._ColorAttribute(
_textattributes._ForegroundColorAttr, _TEXT_COLORS
)
bg = _textattributes._ColorAttribute(
_textattributes._BackgroundColorAttr, _TEXT_COLORS
)
attrs = {
"bold": insults.BOLD,
"blink": insults.BLINK,
"underline": insults.UNDERLINE,
"reverseVideo": insults.REVERSE_VIDEO,
}
def assembleFormattedText(formatted):
"""
Assemble formatted text from structured information.
Currently handled formatting includes: bold, blink, reverse, underline and
color codes.
For example::
from twisted.conch.insults.text import attributes as A
assembleFormattedText(
A.normal[A.bold['Time: '], A.fg.lightRed['Now!']])
Would produce "Time: " in bold formatting, followed by "Now!" with a
foreground color of light red and without any additional formatting.
@param formatted: Structured text and attributes.
@rtype: L{str}
@return: String containing VT102 control sequences that mimic those
specified by C{formatted}.
@see: L{twisted.conch.insults.text._CharacterAttributes}
@since: 13.1
"""
return _textattributes.flatten(formatted, helper._FormattingState(), "toVT102")
attributes = _CharacterAttributes()
__all__ = ["attributes", "flatten"]

View File

@@ -0,0 +1,936 @@
# -*- test-case-name: twisted.conch.test.test_window -*-
"""
Simple insults-based widget library
@author: Jp Calderone
"""
from __future__ import annotations
import array
from twisted.conch.insults import helper, insults
from twisted.python import text as tptext
class YieldFocus(Exception):
"""
Input focus manipulation exception
"""
class BoundedTerminalWrapper:
def __init__(self, terminal, width, height, xoff, yoff):
self.width = width
self.height = height
self.xoff = xoff
self.yoff = yoff
self.terminal = terminal
self.cursorForward = terminal.cursorForward
self.selectCharacterSet = terminal.selectCharacterSet
self.selectGraphicRendition = terminal.selectGraphicRendition
self.saveCursor = terminal.saveCursor
self.restoreCursor = terminal.restoreCursor
def cursorPosition(self, x, y):
return self.terminal.cursorPosition(
self.xoff + min(self.width, x), self.yoff + min(self.height, y)
)
def cursorHome(self):
return self.terminal.cursorPosition(self.xoff, self.yoff)
def write(self, data):
return self.terminal.write(data)
class Widget:
focused = False
parent = None
dirty = False
width: int | None = None
height: int | None = None
def repaint(self):
if not self.dirty:
self.dirty = True
if self.parent is not None and not self.parent.dirty:
self.parent.repaint()
def filthy(self):
self.dirty = True
def redraw(self, width, height, terminal):
self.filthy()
self.draw(width, height, terminal)
def draw(self, width, height, terminal):
if width != self.width or height != self.height or self.dirty:
self.width = width
self.height = height
self.dirty = False
self.render(width, height, terminal)
def render(self, width, height, terminal):
pass
def sizeHint(self):
return None
def keystrokeReceived(self, keyID, modifier):
if keyID == b"\t":
self.tabReceived(modifier)
elif keyID == b"\x7f":
self.backspaceReceived()
elif keyID in insults.FUNCTION_KEYS:
self.functionKeyReceived(keyID, modifier)
else:
self.characterReceived(keyID, modifier)
def tabReceived(self, modifier):
# XXX TODO - Handle shift+tab
raise YieldFocus()
def focusReceived(self):
"""
Called when focus is being given to this widget.
May raise YieldFocus is this widget does not want focus.
"""
self.focused = True
self.repaint()
def focusLost(self):
self.focused = False
self.repaint()
def backspaceReceived(self):
pass
def functionKeyReceived(self, keyID, modifier):
name = keyID
if not isinstance(keyID, str):
name = name.decode("utf-8")
# Peel off the square brackets added by the computed definition of
# twisted.conch.insults.insults.FUNCTION_KEYS.
methodName = "func_" + name[1:-1]
func = getattr(self, methodName, None)
if func is not None:
func(modifier)
def characterReceived(self, keyID, modifier):
pass
class ContainerWidget(Widget):
"""
@ivar focusedChild: The contained widget which currently has
focus, or None.
"""
focusedChild = None
focused = False
def __init__(self):
Widget.__init__(self)
self.children = []
def addChild(self, child):
assert child.parent is None
child.parent = self
self.children.append(child)
if self.focusedChild is None and self.focused:
try:
child.focusReceived()
except YieldFocus:
pass
else:
self.focusedChild = child
self.repaint()
def remChild(self, child):
assert child.parent is self
child.parent = None
self.children.remove(child)
self.repaint()
def filthy(self):
for ch in self.children:
ch.filthy()
Widget.filthy(self)
def render(self, width, height, terminal):
for ch in self.children:
ch.draw(width, height, terminal)
def changeFocus(self):
self.repaint()
if self.focusedChild is not None:
self.focusedChild.focusLost()
focusedChild = self.focusedChild
self.focusedChild = None
try:
curFocus = self.children.index(focusedChild) + 1
except ValueError:
raise YieldFocus()
else:
curFocus = 0
while curFocus < len(self.children):
try:
self.children[curFocus].focusReceived()
except YieldFocus:
curFocus += 1
else:
self.focusedChild = self.children[curFocus]
return
# None of our children wanted focus
raise YieldFocus()
def focusReceived(self):
self.changeFocus()
self.focused = True
def keystrokeReceived(self, keyID, modifier):
if self.focusedChild is not None:
try:
self.focusedChild.keystrokeReceived(keyID, modifier)
except YieldFocus:
self.changeFocus()
self.repaint()
else:
Widget.keystrokeReceived(self, keyID, modifier)
class TopWindow(ContainerWidget):
"""
A top-level container object which provides focus wrap-around and paint
scheduling.
@ivar painter: A no-argument callable which will be invoked when this
widget needs to be redrawn.
@ivar scheduler: A one-argument callable which will be invoked with a
no-argument callable and should arrange for it to invoked at some point in
the near future. The no-argument callable will cause this widget and all
its children to be redrawn. It is typically beneficial for the no-argument
callable to be invoked at the end of handling for whatever event is
currently active; for example, it might make sense to call it at the end of
L{twisted.conch.insults.insults.ITerminalProtocol.keystrokeReceived}.
Note, however, that since calls to this may also be made in response to no
apparent event, arrangements should be made for the function to be called
even if an event handler such as C{keystrokeReceived} is not on the call
stack (eg, using
L{reactor.callLater<twisted.internet.interfaces.IReactorTime.callLater>}
with a short timeout).
"""
focused = True
def __init__(self, painter, scheduler):
ContainerWidget.__init__(self)
self.painter = painter
self.scheduler = scheduler
_paintCall = None
def repaint(self):
if self._paintCall is None:
self._paintCall = object()
self.scheduler(self._paint)
ContainerWidget.repaint(self)
def _paint(self):
self._paintCall = None
self.painter()
def changeFocus(self):
try:
ContainerWidget.changeFocus(self)
except YieldFocus:
try:
ContainerWidget.changeFocus(self)
except YieldFocus:
pass
def keystrokeReceived(self, keyID, modifier):
try:
ContainerWidget.keystrokeReceived(self, keyID, modifier)
except YieldFocus:
self.changeFocus()
class AbsoluteBox(ContainerWidget):
def moveChild(self, child, x, y):
for n in range(len(self.children)):
if self.children[n][0] is child:
self.children[n] = (child, x, y)
break
else:
raise ValueError("No such child", child)
def render(self, width, height, terminal):
for ch, x, y in self.children:
wrap = BoundedTerminalWrapper(terminal, width - x, height - y, x, y)
ch.draw(width, height, wrap)
class _Box(ContainerWidget):
TOP, CENTER, BOTTOM = range(3)
def __init__(self, gravity=CENTER):
ContainerWidget.__init__(self)
self.gravity = gravity
def sizeHint(self):
height = 0
width = 0
for ch in self.children:
hint = ch.sizeHint()
if hint is None:
hint = (None, None)
if self.variableDimension == 0:
if hint[0] is None:
width = None
elif width is not None:
width += hint[0]
if hint[1] is None:
height = None
elif height is not None:
height = max(height, hint[1])
else:
if hint[0] is None:
width = None
elif width is not None:
width = max(width, hint[0])
if hint[1] is None:
height = None
elif height is not None:
height += hint[1]
return width, height
def render(self, width, height, terminal):
if not self.children:
return
greedy = 0
wants = []
for ch in self.children:
hint = ch.sizeHint()
if hint is None:
hint = (None, None)
if hint[self.variableDimension] is None:
greedy += 1
wants.append(hint[self.variableDimension])
length = (width, height)[self.variableDimension]
totalWant = sum(w for w in wants if w is not None)
if greedy:
leftForGreedy = int((length - totalWant) / greedy)
widthOffset = heightOffset = 0
for want, ch in zip(wants, self.children):
if want is None:
want = leftForGreedy
subWidth, subHeight = width, height
if self.variableDimension == 0:
subWidth = want
else:
subHeight = want
wrap = BoundedTerminalWrapper(
terminal,
subWidth,
subHeight,
widthOffset,
heightOffset,
)
ch.draw(subWidth, subHeight, wrap)
if self.variableDimension == 0:
widthOffset += want
else:
heightOffset += want
class HBox(_Box):
variableDimension = 0
class VBox(_Box):
variableDimension = 1
class Packer(ContainerWidget):
def render(self, width, height, terminal):
if not self.children:
return
root = int(len(self.children) ** 0.5 + 0.5)
boxes = [VBox() for n in range(root)]
for n, ch in enumerate(self.children):
boxes[n % len(boxes)].addChild(ch)
h = HBox()
map(h.addChild, boxes)
h.render(width, height, terminal)
class Canvas(Widget):
focused = False
contents = None
def __init__(self):
Widget.__init__(self)
self.resize(1, 1)
def resize(self, width, height):
contents = array.array("B", b" " * width * height)
if self.contents is not None:
for x in range(min(width, self._width)):
for y in range(min(height, self._height)):
contents[width * y + x] = self[x, y]
self.contents = contents
self._width = width
self._height = height
if self.x >= width:
self.x = width - 1
if self.y >= height:
self.y = height - 1
def __getitem__(self, index):
(x, y) = index
return self.contents[(self._width * y) + x]
def __setitem__(self, index, value):
(x, y) = index
self.contents[(self._width * y) + x] = value
def clear(self):
self.contents = array.array("B", b" " * len(self.contents))
def render(self, width, height, terminal):
if not width or not height:
return
if width != self._width or height != self._height:
self.resize(width, height)
for i in range(height):
terminal.cursorPosition(0, i)
text = self.contents[
self._width * i : self._width * i + self._width
].tobytes()
text = text[:width]
terminal.write(text)
def horizontalLine(terminal, y, left, right):
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
terminal.cursorPosition(left, y)
terminal.write(b"\161" * (right - left))
terminal.selectCharacterSet(insults.CS_US, insults.G0)
def verticalLine(terminal, x, top, bottom):
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
for n in range(top, bottom):
terminal.cursorPosition(x, n)
terminal.write(b"\170")
terminal.selectCharacterSet(insults.CS_US, insults.G0)
def rectangle(terminal, position, dimension):
"""
Draw a rectangle
@type position: L{tuple}
@param position: A tuple of the (top, left) coordinates of the rectangle.
@type dimension: L{tuple}
@param dimension: A tuple of the (width, height) size of the rectangle.
"""
(top, left) = position
(width, height) = dimension
terminal.selectCharacterSet(insults.CS_DRAWING, insults.G0)
terminal.cursorPosition(top, left)
terminal.write(b"\154")
terminal.write(b"\161" * (width - 2))
terminal.write(b"\153")
for n in range(height - 2):
terminal.cursorPosition(left, top + n + 1)
terminal.write(b"\170")
terminal.cursorForward(width - 2)
terminal.write(b"\170")
terminal.cursorPosition(0, top + height - 1)
terminal.write(b"\155")
terminal.write(b"\161" * (width - 2))
terminal.write(b"\152")
terminal.selectCharacterSet(insults.CS_US, insults.G0)
class Border(Widget):
def __init__(self, containee):
Widget.__init__(self)
self.containee = containee
self.containee.parent = self
def focusReceived(self):
return self.containee.focusReceived()
def focusLost(self):
return self.containee.focusLost()
def keystrokeReceived(self, keyID, modifier):
return self.containee.keystrokeReceived(keyID, modifier)
def sizeHint(self):
hint = self.containee.sizeHint()
if hint is None:
hint = (None, None)
if hint[0] is None:
x = None
else:
x = hint[0] + 2
if hint[1] is None:
y = None
else:
y = hint[1] + 2
return x, y
def filthy(self):
self.containee.filthy()
Widget.filthy(self)
def render(self, width, height, terminal):
if self.containee.focused:
terminal.write(b"\x1b[31m")
rectangle(terminal, (0, 0), (width, height))
terminal.write(b"\x1b[0m")
wrap = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
self.containee.draw(width - 2, height - 2, wrap)
class Button(Widget):
def __init__(self, label, onPress):
Widget.__init__(self)
self.label = label
self.onPress = onPress
def sizeHint(self):
return len(self.label), 1
def characterReceived(self, keyID, modifier):
if keyID == b"\r":
self.onPress()
def render(self, width, height, terminal):
terminal.cursorPosition(0, 0)
if self.focused:
terminal.write(b"\x1b[1m" + self.label + b"\x1b[0m")
else:
terminal.write(self.label)
class TextInput(Widget):
def __init__(self, maxwidth, onSubmit):
Widget.__init__(self)
self.onSubmit = onSubmit
self.maxwidth = maxwidth
self.buffer = b""
self.cursor = 0
def setText(self, text):
self.buffer = text[: self.maxwidth]
self.cursor = len(self.buffer)
self.repaint()
def func_LEFT_ARROW(self, modifier):
if self.cursor > 0:
self.cursor -= 1
self.repaint()
def func_RIGHT_ARROW(self, modifier):
if self.cursor < len(self.buffer):
self.cursor += 1
self.repaint()
def backspaceReceived(self):
if self.cursor > 0:
self.buffer = self.buffer[: self.cursor - 1] + self.buffer[self.cursor :]
self.cursor -= 1
self.repaint()
def characterReceived(self, keyID, modifier):
if keyID == b"\r":
self.onSubmit(self.buffer)
else:
if len(self.buffer) < self.maxwidth:
self.buffer = (
self.buffer[: self.cursor] + keyID + self.buffer[self.cursor :]
)
self.cursor += 1
self.repaint()
def sizeHint(self):
return self.maxwidth + 1, 1
def render(self, width, height, terminal):
currentText = self._renderText()
terminal.cursorPosition(0, 0)
if self.focused:
terminal.write(currentText[: self.cursor])
cursor(terminal, currentText[self.cursor : self.cursor + 1] or b" ")
terminal.write(currentText[self.cursor + 1 :])
terminal.write(b" " * (self.maxwidth - len(currentText) + 1))
else:
more = self.maxwidth - len(currentText)
terminal.write(currentText + b"_" * more)
def _renderText(self):
return self.buffer
class PasswordInput(TextInput):
def _renderText(self):
return "*" * len(self.buffer)
class TextOutput(Widget):
text = b""
def __init__(self, size=None):
Widget.__init__(self)
self.size = size
def sizeHint(self):
return self.size
def render(self, width, height, terminal):
terminal.cursorPosition(0, 0)
text = self.text[:width]
terminal.write(text + b" " * (width - len(text)))
def setText(self, text):
self.text = text
self.repaint()
def focusReceived(self):
raise YieldFocus()
class TextOutputArea(TextOutput):
WRAP, TRUNCATE = range(2)
def __init__(self, size=None, longLines=WRAP):
TextOutput.__init__(self, size)
self.longLines = longLines
def render(self, width, height, terminal):
n = 0
inputLines = self.text.splitlines()
outputLines = []
while inputLines:
if self.longLines == self.WRAP:
line = inputLines.pop(0)
if not isinstance(line, str):
line = line.decode("utf-8")
wrappedLines = []
for wrappedLine in tptext.greedyWrap(line, width):
if not isinstance(wrappedLine, bytes):
wrappedLine = wrappedLine.encode("utf-8")
wrappedLines.append(wrappedLine)
outputLines.extend(wrappedLines or [b""])
else:
outputLines.append(inputLines.pop(0)[:width])
if len(outputLines) >= height:
break
for n, L in enumerate(outputLines[:height]):
terminal.cursorPosition(0, n)
terminal.write(L)
class Viewport(Widget):
_xOffset = 0
_yOffset = 0
@property
def xOffset(self):
return self._xOffset
@xOffset.setter
def xOffset(self, value):
if self._xOffset != value:
self._xOffset = value
self.repaint()
@property
def yOffset(self):
return self._yOffset
@yOffset.setter
def yOffset(self, value):
if self._yOffset != value:
self._yOffset = value
self.repaint()
_width = 160
_height = 24
def __init__(self, containee):
Widget.__init__(self)
self.containee = containee
self.containee.parent = self
self._buf = helper.TerminalBuffer()
self._buf.width = self._width
self._buf.height = self._height
self._buf.connectionMade()
def filthy(self):
self.containee.filthy()
Widget.filthy(self)
def render(self, width, height, terminal):
self.containee.draw(self._width, self._height, self._buf)
# XXX /Lame/
for y, line in enumerate(
self._buf.lines[self._yOffset : self._yOffset + height]
):
terminal.cursorPosition(0, y)
n = 0
for n, (ch, attr) in enumerate(line[self._xOffset : self._xOffset + width]):
if ch is self._buf.void:
ch = b" "
terminal.write(ch)
if n < width:
terminal.write(b" " * (width - n - 1))
class _Scrollbar(Widget):
def __init__(self, onScroll):
Widget.__init__(self)
self.onScroll = onScroll
self.percent = 0.0
def smaller(self):
self.percent = min(1.0, max(0.0, self.onScroll(-1)))
self.repaint()
def bigger(self):
self.percent = min(1.0, max(0.0, self.onScroll(+1)))
self.repaint()
class HorizontalScrollbar(_Scrollbar):
def sizeHint(self):
return (None, 1)
def func_LEFT_ARROW(self, modifier):
self.smaller()
def func_RIGHT_ARROW(self, modifier):
self.bigger()
_left = "\N{BLACK LEFT-POINTING TRIANGLE}"
_right = "\N{BLACK RIGHT-POINTING TRIANGLE}"
_bar = "\N{LIGHT SHADE}"
_slider = "\N{DARK SHADE}"
def render(self, width, height, terminal):
terminal.cursorPosition(0, 0)
n = width - 3
before = int(n * self.percent)
after = n - before
me = (
self._left
+ (self._bar * before)
+ self._slider
+ (self._bar * after)
+ self._right
)
terminal.write(me.encode("utf-8"))
class VerticalScrollbar(_Scrollbar):
def sizeHint(self):
return (1, None)
def func_UP_ARROW(self, modifier):
self.smaller()
def func_DOWN_ARROW(self, modifier):
self.bigger()
_up = "\N{BLACK UP-POINTING TRIANGLE}"
_down = "\N{BLACK DOWN-POINTING TRIANGLE}"
_bar = "\N{LIGHT SHADE}"
_slider = "\N{DARK SHADE}"
def render(self, width, height, terminal):
terminal.cursorPosition(0, 0)
knob = int(self.percent * (height - 2))
terminal.write(self._up.encode("utf-8"))
for i in range(1, height - 1):
terminal.cursorPosition(0, i)
if i != (knob + 1):
terminal.write(self._bar.encode("utf-8"))
else:
terminal.write(self._slider.encode("utf-8"))
terminal.cursorPosition(0, height - 1)
terminal.write(self._down.encode("utf-8"))
class ScrolledArea(Widget):
"""
A L{ScrolledArea} contains another widget wrapped in a viewport and
vertical and horizontal scrollbars for moving the viewport around.
"""
def __init__(self, containee):
Widget.__init__(self)
self._viewport = Viewport(containee)
self._horiz = HorizontalScrollbar(self._horizScroll)
self._vert = VerticalScrollbar(self._vertScroll)
for w in self._viewport, self._horiz, self._vert:
w.parent = self
def _horizScroll(self, n):
self._viewport.xOffset += n
self._viewport.xOffset = max(0, self._viewport.xOffset)
return self._viewport.xOffset / 25.0
def _vertScroll(self, n):
self._viewport.yOffset += n
self._viewport.yOffset = max(0, self._viewport.yOffset)
return self._viewport.yOffset / 25.0
def func_UP_ARROW(self, modifier):
self._vert.smaller()
def func_DOWN_ARROW(self, modifier):
self._vert.bigger()
def func_LEFT_ARROW(self, modifier):
self._horiz.smaller()
def func_RIGHT_ARROW(self, modifier):
self._horiz.bigger()
def filthy(self):
self._viewport.filthy()
self._horiz.filthy()
self._vert.filthy()
Widget.filthy(self)
def render(self, width, height, terminal):
wrapper = BoundedTerminalWrapper(terminal, width - 2, height - 2, 1, 1)
self._viewport.draw(width - 2, height - 2, wrapper)
if self.focused:
terminal.write(b"\x1b[31m")
horizontalLine(terminal, 0, 1, width - 1)
verticalLine(terminal, 0, 1, height - 1)
self._vert.draw(
1, height - 1, BoundedTerminalWrapper(terminal, 1, height - 1, width - 1, 0)
)
self._horiz.draw(
width, 1, BoundedTerminalWrapper(terminal, width, 1, 0, height - 1)
)
terminal.write(b"\x1b[0m")
def cursor(terminal, ch):
terminal.saveCursor()
terminal.selectGraphicRendition(str(insults.REVERSE_VIDEO))
terminal.write(ch)
terminal.restoreCursor()
terminal.cursorForward()
class Selection(Widget):
# Index into the sequence
focusedIndex = 0
# Offset into the displayed subset of the sequence
renderOffset = 0
def __init__(self, sequence, onSelect, minVisible=None):
Widget.__init__(self)
self.sequence = sequence
self.onSelect = onSelect
self.minVisible = minVisible
if minVisible is not None:
self._width = max(map(len, self.sequence))
def sizeHint(self):
if self.minVisible is not None:
return self._width, self.minVisible
def func_UP_ARROW(self, modifier):
if self.focusedIndex > 0:
self.focusedIndex -= 1
if self.renderOffset > 0:
self.renderOffset -= 1
self.repaint()
def func_PGUP(self, modifier):
if self.renderOffset != 0:
self.focusedIndex -= self.renderOffset
self.renderOffset = 0
else:
self.focusedIndex = max(0, self.focusedIndex - self.height)
self.repaint()
def func_DOWN_ARROW(self, modifier):
if self.focusedIndex < len(self.sequence) - 1:
self.focusedIndex += 1
if self.renderOffset < self.height - 1:
self.renderOffset += 1
self.repaint()
def func_PGDN(self, modifier):
if self.renderOffset != self.height - 1:
change = self.height - self.renderOffset - 1
if change + self.focusedIndex >= len(self.sequence):
change = len(self.sequence) - self.focusedIndex - 1
self.focusedIndex += change
self.renderOffset = self.height - 1
else:
self.focusedIndex = min(
len(self.sequence) - 1, self.focusedIndex + self.height
)
self.repaint()
def characterReceived(self, keyID, modifier):
if keyID == b"\r":
self.onSelect(self.sequence[self.focusedIndex])
def render(self, width, height, terminal):
self.height = height
start = self.focusedIndex - self.renderOffset
if start > len(self.sequence) - height:
start = max(0, len(self.sequence) - height)
elements = self.sequence[start : start + height]
for n, ele in enumerate(elements):
terminal.cursorPosition(0, n)
if n == self.renderOffset:
terminal.saveCursor()
if self.focused:
modes = str(insults.REVERSE_VIDEO), str(insults.BOLD)
else:
modes = (str(insults.REVERSE_VIDEO),)
terminal.selectGraphicRendition(*modes)
text = ele[:width]
terminal.write(text + (b" " * (width - len(text))))
if n == self.renderOffset:
terminal.restoreCursor()

View File

@@ -0,0 +1,456 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
This module contains interfaces defined for the L{twisted.conch} package.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from zope.interface import Attribute, Interface
if TYPE_CHECKING:
from twisted.conch.ssh.keys import Key
class IConchUser(Interface):
"""
A user who has been authenticated to Cred through Conch. This is
the interface between the SSH connection and the user.
"""
conn = Attribute("The SSHConnection object for this user.")
def lookupChannel(channelType, windowSize, maxPacket, data):
"""
The other side requested a channel of some sort.
C{channelType} is the type of channel being requested,
as an ssh connection protocol channel type.
C{data} is any other packet data (often nothing).
We return a subclass of L{SSHChannel<ssh.channel.SSHChannel>}. If
the channel type is unknown, we return C{None}.
For other failures, we raise an exception. If a
L{ConchError<error.ConchError>} is raised, the C{.value} will
be the message, and the C{.data} will be the error code.
@param channelType: The requested channel type
@type channelType: L{bytes}
@param windowSize: The initial size of the remote window
@type windowSize: L{int}
@param maxPacket: The largest packet we should send
@type maxPacket: L{int}
@param data: Additional request data
@type data: L{bytes}
@rtype: a subclass of L{SSHChannel} or L{None}
"""
def lookupSubsystem(subsystem, data):
"""
The other side requested a subsystem.
We return a L{Protocol} implementing the requested subsystem.
If the subsystem is not available, we return C{None}.
@param subsystem: The name of the subsystem being requested
@type subsystem: L{bytes}
@param data: Additional request data (often nothing)
@type data: L{bytes}
@rtype: L{Protocol} or L{None}
"""
def gotGlobalRequest(requestType, data):
"""
A global request was sent from the other side.
We return a true value on success or a false value on failure.
If we indicate success by returning a tuple, its second item
will be sent to the other side as additional response data.
@param requestType: The type of the request
@type requestType: L{bytes}
@param data: Additional request data
@type data: L{bytes}
@rtype: boolean or L{tuple}
"""
class ISession(Interface):
def getPty(term, windowSize, modes):
"""
Get a pseudo-terminal for use by a shell or command.
If a pseudo-terminal is not available, or the request otherwise
fails, raise an exception.
"""
def openShell(proto):
"""
Open a shell and connect it to proto.
@param proto: a L{ProcessProtocol} instance.
"""
def execCommand(proto, command):
"""
Execute a command.
@param proto: a L{ProcessProtocol} instance.
"""
def windowChanged(newWindowSize):
"""
Called when the size of the remote screen has changed.
"""
def eofReceived():
"""
Called when the other side has indicated no more data will be sent.
"""
def closed():
"""
Called when the session is closed.
"""
class EnvironmentVariableNotPermitted(ValueError):
"""Setting this environment variable in this session is not permitted."""
class ISessionSetEnv(Interface):
"""A session that can set environment variables."""
def setEnv(name, value):
"""
Set an environment variable for the shell or command to be started.
From U{RFC 4254, section 6.4
<https://tools.ietf.org/html/rfc4254#section-6.4>}: "Uncontrolled
setting of environment variables in a privileged process can be a
security hazard. It is recommended that implementations either
maintain a list of allowable variable names or only set environment
variables after the server process has dropped sufficient
privileges."
(OpenSSH refuses all environment variables by default, but has an
C{AcceptEnv} configuration option to select specific variables to
accept.)
@param name: The name of the environment variable to set.
@type name: L{bytes}
@param value: The value of the environment variable to set.
@type value: L{bytes}
@raise EnvironmentVariableNotPermitted: if setting this environment
variable is not permitted.
"""
class ISFTPServer(Interface):
"""
SFTP subsystem for server-side communication.
Each method should check to verify that the user has permission for
their actions.
"""
avatar = Attribute(
"""
The avatar returned by the Realm that we are authenticated with,
and represents the logged-in user.
"""
)
def gotVersion(otherVersion, extData):
"""
Called when the client sends their version info.
otherVersion is an integer representing the version of the SFTP
protocol they are claiming.
extData is a dictionary of extended_name : extended_data items.
These items are sent by the client to indicate additional features.
This method should return a dictionary of extended_name : extended_data
items. These items are the additional features (if any) supported
by the server.
"""
return {}
def openFile(filename, flags, attrs):
"""
Called when the clients asks to open a file.
@param filename: a string representing the file to open.
@param flags: an integer of the flags to open the file with, ORed
together. The flags and their values are listed at the bottom of
L{twisted.conch.ssh.filetransfer} as FXF_*.
@param attrs: a list of attributes to open the file with. It is a
dictionary, consisting of 0 or more keys. The possible keys are::
size: the size of the file in bytes
uid: the user ID of the file as an integer
gid: the group ID of the file as an integer
permissions: the permissions of the file with as an integer.
the bit representation of this field is defined by POSIX.
atime: the access time of the file as seconds since the epoch.
mtime: the modification time of the file as seconds since the epoch.
ext_*: extended attributes. The server is not required to
understand this, but it may.
NOTE: there is no way to indicate text or binary files. it is up
to the SFTP client to deal with this.
This method returns an object that meets the ISFTPFile interface.
Alternatively, it can return a L{Deferred} that will be called back
with the object.
"""
def removeFile(filename):
"""
Remove the given file.
This method returns when the remove succeeds, or a Deferred that is
called back when it succeeds.
@param filename: the name of the file as a string.
"""
def renameFile(oldpath, newpath):
"""
Rename the given file.
This method returns when the rename succeeds, or a L{Deferred} that is
called back when it succeeds. If the rename fails, C{renameFile} will
raise an implementation-dependent exception.
@param oldpath: the current location of the file.
@param newpath: the new file name.
"""
def makeDirectory(path, attrs):
"""
Make a directory.
This method returns when the directory is created, or a Deferred that
is called back when it is created.
@param path: the name of the directory to create as a string.
@param attrs: a dictionary of attributes to create the directory with.
Its meaning is the same as the attrs in the L{openFile} method.
"""
def removeDirectory(path):
"""
Remove a directory (non-recursively)
It is an error to remove a directory that has files or directories in
it.
This method returns when the directory is removed, or a Deferred that
is called back when it is removed.
@param path: the directory to remove.
"""
def openDirectory(path):
"""
Open a directory for scanning.
This method returns an iterable object that has a close() method,
or a Deferred that is called back with same.
The close() method is called when the client is finished reading
from the directory. At this point, the iterable will no longer
be used.
The iterable should return triples of the form (filename,
longname, attrs) or Deferreds that return the same. The
sequence must support __getitem__, but otherwise may be any
'sequence-like' object.
filename is the name of the file relative to the directory.
logname is an expanded format of the filename. The recommended format
is:
-rwxr-xr-x 1 mjos staff 348911 Mar 25 14:29 t-filexfer
1234567890 123 12345678 12345678 12345678 123456789012
The first line is sample output, the second is the length of the field.
The fields are: permissions, link count, user owner, group owner,
size in bytes, modification time.
attrs is a dictionary in the format of the attrs argument to openFile.
@param path: the directory to open.
"""
def getAttrs(path, followLinks):
"""
Return the attributes for the given path.
This method returns a dictionary in the same format as the attrs
argument to openFile or a Deferred that is called back with same.
@param path: the path to return attributes for as a string.
@param followLinks: a boolean. If it is True, follow symbolic links
and return attributes for the real path at the base. If it is False,
return attributes for the specified path.
"""
def setAttrs(path, attrs):
"""
Set the attributes for the path.
This method returns when the attributes are set or a Deferred that is
called back when they are.
@param path: the path to set attributes for as a string.
@param attrs: a dictionary in the same format as the attrs argument to
L{openFile}.
"""
def readLink(path):
"""
Find the root of a set of symbolic links.
This method returns the target of the link, or a Deferred that
returns the same.
@param path: the path of the symlink to read.
"""
def makeLink(linkPath, targetPath):
"""
Create a symbolic link.
This method returns when the link is made, or a Deferred that
returns the same.
@param linkPath: the pathname of the symlink as a string.
@param targetPath: the path of the target of the link as a string.
"""
def realPath(path):
"""
Convert any path to an absolute path.
This method returns the absolute path as a string, or a Deferred
that returns the same.
@param path: the path to convert as a string.
"""
def extendedRequest(extendedName, extendedData):
"""
This is the extension mechanism for SFTP. The other side can send us
arbitrary requests.
If we don't implement the request given by extendedName, raise
NotImplementedError.
The return value is a string, or a Deferred that will be called
back with a string.
@param extendedName: the name of the request as a string.
@param extendedData: the data the other side sent with the request,
as a string.
"""
class IKnownHostEntry(Interface):
"""
A L{IKnownHostEntry} is an entry in an OpenSSH-formatted C{known_hosts}
file.
@since: 8.2
"""
def matchesKey(key: Key) -> bool:
"""
Return True if this entry matches the given Key object, False
otherwise.
@param key: The key object to match against.
"""
def matchesHost(hostname: bytes) -> bool:
"""
Return True if this entry matches the given hostname, False otherwise.
Note that this does no name resolution; if you want to match an IP
address, you have to resolve it yourself, and pass it in as a dotted
quad string.
@param hostname: The hostname to match against.
"""
def toString() -> bytes:
"""
@return: a serialized string representation of this entry, suitable for
inclusion in a known_hosts file. (Newline not included.)
"""
class ISFTPFile(Interface):
"""
This represents an open file on the server. An object adhering to this
interface should be returned from L{openFile}().
"""
def close():
"""
Close the file.
This method returns nothing if the close succeeds immediately, or a
Deferred that is called back when the close succeeds.
"""
def readChunk(offset, length):
"""
Read from the file.
If EOF is reached before any data is read, raise EOFError.
This method returns the data as a string, or a Deferred that is
called back with same.
@param offset: an integer that is the index to start from in the file.
@param length: the maximum length of data to return. The actual amount
returned may less than this. For normal disk files, however,
this should read the requested number (up to the end of the file).
"""
def writeChunk(offset, data):
"""
Write to the file.
This method returns when the write completes, or a Deferred that is
called when it completes.
@param offset: an integer that is the index to start from in the file.
@param data: a string that is the data to write.
"""
def getAttrs():
"""
Return the attributes for the file.
This method returns a dictionary in the same format as the attrs
argument to L{openFile} or a L{Deferred} that is called back with same.
"""
def setAttrs(attrs):
"""
Set the attributes for the file.
This method returns when the attributes are set or a Deferred that is
called back when they are.
@param attrs: a dictionary in the same format as the attrs argument to
L{openFile}.
"""

View File

@@ -0,0 +1,104 @@
# -*- test-case-name: twisted.conch.test.test_cftp -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
import array
import stat
from time import localtime, strftime, time
# Locale-independent month names to use instead of strftime's
_MONTH_NAMES = dict(
list(
zip(
list(range(1, 13)),
"Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(),
)
)
)
def lsLine(name, s):
"""
Build an 'ls' line for a file ('file' in its generic sense, it
can be of any type).
"""
mode = s.st_mode
perms = array.array("B", b"-" * 10)
ft = stat.S_IFMT(mode)
if stat.S_ISDIR(ft):
perms[0] = ord("d")
elif stat.S_ISCHR(ft):
perms[0] = ord("c")
elif stat.S_ISBLK(ft):
perms[0] = ord("b")
elif stat.S_ISREG(ft):
perms[0] = ord("-")
elif stat.S_ISFIFO(ft):
perms[0] = ord("f")
elif stat.S_ISLNK(ft):
perms[0] = ord("l")
elif stat.S_ISSOCK(ft):
perms[0] = ord("s")
else:
perms[0] = ord("!")
# User
if mode & stat.S_IRUSR:
perms[1] = ord("r")
if mode & stat.S_IWUSR:
perms[2] = ord("w")
if mode & stat.S_IXUSR:
perms[3] = ord("x")
# Group
if mode & stat.S_IRGRP:
perms[4] = ord("r")
if mode & stat.S_IWGRP:
perms[5] = ord("w")
if mode & stat.S_IXGRP:
perms[6] = ord("x")
# Other
if mode & stat.S_IROTH:
perms[7] = ord("r")
if mode & stat.S_IWOTH:
perms[8] = ord("w")
if mode & stat.S_IXOTH:
perms[9] = ord("x")
# Suid/sgid
if mode & stat.S_ISUID:
if perms[3] == ord("x"):
perms[3] = ord("s")
else:
perms[3] = ord("S")
if mode & stat.S_ISGID:
if perms[6] == ord("x"):
perms[6] = ord("s")
else:
perms[6] = ord("S")
if isinstance(name, bytes):
name = name.decode("utf-8")
lsPerms = perms.tobytes()
lsPerms = lsPerms.decode("utf-8")
lsresult = [
lsPerms,
str(s.st_nlink).rjust(5),
" ",
str(s.st_uid).ljust(9),
str(s.st_gid).ljust(9),
str(s.st_size).rjust(8),
" ",
]
# Need to specify the month manually, as strftime depends on locale
ttup = localtime(s.st_mtime)
sixmonths = 60 * 60 * 24 * 7 * 26
if s.st_mtime + sixmonths < time(): # Last edited more than 6mo ago
strtime = strftime("%%s %d %Y ", ttup)
else:
strtime = strftime("%%s %d %H:%M ", ttup)
lsresult.append(strtime % (_MONTH_NAMES[ttup[1]],))
lsresult.append(name)
return "".join(lsresult)
__all__ = ["lsLine"]

View File

@@ -0,0 +1,392 @@
# -*- test-case-name: twisted.conch.test.test_manhole -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Line-input oriented interactive interpreter loop.
Provides classes for handling Python source input and arbitrary output
interactively from a Twisted application. Also included is syntax coloring
code with support for VT102 terminals, control code handling (^C, ^D, ^Q),
and reasonable handling of Deferreds.
@author: Jp Calderone
"""
import code
import sys
import tokenize
from io import BytesIO
from traceback import format_exception
from types import TracebackType
from typing import Type
from twisted.conch import recvline
from twisted.internet import defer
from twisted.python.htmlizer import TokenPrinter
from twisted.python.monkey import MonkeyPatcher
class FileWrapper:
"""
Minimal write-file-like object.
Writes are translated into addOutput calls on an object passed to
__init__. Newlines are also converted from network to local style.
"""
softspace = 0
state = "normal"
def __init__(self, o):
self.o = o
def flush(self):
pass
def write(self, data):
self.o.addOutput(data.replace("\r\n", "\n"))
def writelines(self, lines):
self.write("".join(lines))
class ManholeInterpreter(code.InteractiveInterpreter):
"""
Interactive Interpreter with special output and Deferred support.
Aside from the features provided by L{code.InteractiveInterpreter}, this
class captures sys.stdout output and redirects it to the appropriate
location (the Manhole protocol instance). It also treats Deferreds
which reach the top-level specially: each is formatted to the user with
a unique identifier and a new callback and errback added to it, each of
which will format the unique identifier and the result with which the
Deferred fires and then pass it on to the next participant in the
callback chain.
"""
numDeferreds = 0
def __init__(self, handler, locals=None, filename="<console>"):
code.InteractiveInterpreter.__init__(self, locals)
self._pendingDeferreds = {}
self.handler = handler
self.filename = filename
self.resetBuffer()
self.monkeyPatcher = MonkeyPatcher()
self.monkeyPatcher.addPatch(sys, "displayhook", self.displayhook)
self.monkeyPatcher.addPatch(sys, "excepthook", self.excepthook)
self.monkeyPatcher.addPatch(sys, "stdout", FileWrapper(self.handler))
def resetBuffer(self):
"""
Reset the input buffer.
"""
self.buffer = []
def push(self, line):
"""
Push a line to the interpreter.
The line should not have a trailing newline; it may have
internal newlines. The line is appended to a buffer and the
interpreter's runsource() method is called with the
concatenated contents of the buffer as source. If this
indicates that the command was executed or invalid, the buffer
is reset; otherwise, the command is incomplete, and the buffer
is left as it was after the line was appended. The return
value is 1 if more input is required, 0 if the line was dealt
with in some way (this is the same as runsource()).
@param line: line of text
@type line: L{bytes}
@return: L{bool} from L{code.InteractiveInterpreter.runsource}
"""
self.buffer.append(line)
source = b"\n".join(self.buffer)
source = source.decode("utf-8")
more = self.runsource(source, self.filename)
if not more:
self.resetBuffer()
return more
def runcode(self, *a, **kw):
with self.monkeyPatcher:
code.InteractiveInterpreter.runcode(self, *a, **kw)
def excepthook(
self,
excType: Type[BaseException],
excValue: BaseException,
excTraceback: TracebackType,
) -> None:
"""
Format exception tracebacks and write them to the output handler.
"""
code_obj = excTraceback.tb_frame.f_code
if code_obj.co_filename == code.__file__ and code_obj.co_name == "runcode":
traceback = excTraceback.tb_next
else:
# Workaround for https://github.com/python/cpython/issues/122478,
# present e.g. in Python 3.12.6:
traceback = excTraceback
lines = format_exception(excType, excValue, traceback)
self.write("".join(lines))
def displayhook(self, obj):
self.locals["_"] = obj
if isinstance(obj, defer.Deferred):
# XXX Ick, where is my "hasFired()" interface?
if hasattr(obj, "result"):
self.write(repr(obj))
elif id(obj) in self._pendingDeferreds:
self.write("<Deferred #%d>" % (self._pendingDeferreds[id(obj)][0],))
else:
d = self._pendingDeferreds
k = self.numDeferreds
d[id(obj)] = (k, obj)
self.numDeferreds += 1
obj.addCallbacks(
self._cbDisplayDeferred,
self._ebDisplayDeferred,
callbackArgs=(k, obj),
errbackArgs=(k, obj),
)
self.write("<Deferred #%d>" % (k,))
elif obj is not None:
self.write(repr(obj))
def _cbDisplayDeferred(self, result, k, obj):
self.write("Deferred #%d called back: %r" % (k, result), True)
del self._pendingDeferreds[id(obj)]
return result
def _ebDisplayDeferred(self, failure, k, obj):
self.write("Deferred #%d failed: %r" % (k, failure.getErrorMessage()), True)
del self._pendingDeferreds[id(obj)]
return failure
def write(self, data, isAsync=None):
self.handler.addOutput(data, isAsync)
CTRL_C = b"\x03"
CTRL_D = b"\x04"
CTRL_BACKSLASH = b"\x1c"
CTRL_L = b"\x0c"
CTRL_A = b"\x01"
CTRL_E = b"\x05"
class Manhole(recvline.HistoricRecvLine):
r"""
Mediator between a fancy line source and an interactive interpreter.
This accepts lines from its transport and passes them on to a
L{ManholeInterpreter}. Control commands (^C, ^D, ^\) are also handled
with something approximating their normal terminal-mode behavior. It
can optionally be constructed with a dict which will be used as the
local namespace for any code executed.
"""
namespace = None
def __init__(self, namespace=None):
recvline.HistoricRecvLine.__init__(self)
if namespace is not None:
self.namespace = namespace.copy()
def connectionMade(self):
recvline.HistoricRecvLine.connectionMade(self)
self.interpreter = ManholeInterpreter(self, self.namespace)
self.keyHandlers[CTRL_C] = self.handle_INT
self.keyHandlers[CTRL_D] = self.handle_EOF
self.keyHandlers[CTRL_L] = self.handle_FF
self.keyHandlers[CTRL_A] = self.handle_HOME
self.keyHandlers[CTRL_E] = self.handle_END
self.keyHandlers[CTRL_BACKSLASH] = self.handle_QUIT
def handle_INT(self):
"""
Handle ^C as an interrupt keystroke by resetting the current input
variables to their initial state.
"""
self.pn = 0
self.lineBuffer = []
self.lineBufferIndex = 0
self.interpreter.resetBuffer()
self.terminal.nextLine()
self.terminal.write(b"KeyboardInterrupt")
self.terminal.nextLine()
self.terminal.write(self.ps[self.pn])
def handle_EOF(self):
if self.lineBuffer:
self.terminal.write(b"\a")
else:
self.handle_QUIT()
def handle_FF(self):
"""
Handle a 'form feed' byte - generally used to request a screen
refresh/redraw.
"""
self.terminal.eraseDisplay()
self.terminal.cursorHome()
self.drawInputLine()
def handle_QUIT(self):
self.terminal.loseConnection()
def _needsNewline(self):
w = self.terminal.lastWrite
return not w.endswith(b"\n") and not w.endswith(b"\x1bE")
def addOutput(self, data, isAsync=None):
if isAsync:
self.terminal.eraseLine()
self.terminal.cursorBackward(len(self.lineBuffer) + len(self.ps[self.pn]))
self.terminal.write(data)
if isAsync:
if self._needsNewline():
self.terminal.nextLine()
self.terminal.write(self.ps[self.pn])
if self.lineBuffer:
oldBuffer = self.lineBuffer
self.lineBuffer = []
self.lineBufferIndex = 0
self._deliverBuffer(oldBuffer)
def lineReceived(self, line):
more = self.interpreter.push(line)
self.pn = bool(more)
if self._needsNewline():
self.terminal.nextLine()
self.terminal.write(self.ps[self.pn])
class VT102Writer:
"""
Colorizer for Python tokens.
A series of tokens are written to instances of this object. Each is
colored in a particular way. The final line of the result of this is
generally added to the output.
"""
typeToColor = {
"identifier": b"\x1b[31m",
"keyword": b"\x1b[32m",
"parameter": b"\x1b[33m",
"variable": b"\x1b[1;33m",
"string": b"\x1b[35m",
"number": b"\x1b[36m",
"op": b"\x1b[37m",
}
normalColor = b"\x1b[0m"
def __init__(self):
self.written = []
def color(self, type):
r = self.typeToColor.get(type, b"")
return r
def write(self, token, type=None):
if token and token != b"\r":
c = self.color(type)
if c:
self.written.append(c)
self.written.append(token)
if c:
self.written.append(self.normalColor)
def __bytes__(self):
s = b"".join(self.written)
return s.strip(b"\n").splitlines()[-1]
def lastColorizedLine(source):
"""
Tokenize and colorize the given Python source.
Returns a VT102-format colorized version of the last line of C{source}.
@param source: Python source code
@type source: L{str} or L{bytes}
@return: L{bytes} of colorized source
"""
if not isinstance(source, bytes):
source = source.encode("utf-8")
w = VT102Writer()
p = TokenPrinter(w.write).printtoken
s = BytesIO(source)
for token in tokenize.tokenize(s.readline):
(tokenType, string, start, end, line) = token
p(tokenType, string, start, end, line)
return bytes(w)
class ColoredManhole(Manhole):
"""
A REPL which syntax colors input as users type it.
"""
def getSource(self):
"""
Return a string containing the currently entered source.
This is only the code which will be considered for execution
next.
"""
return b"\n".join(self.interpreter.buffer) + b"\n" + b"".join(self.lineBuffer)
def characterReceived(self, ch, moreCharactersComing):
if self.mode == "insert":
self.lineBuffer.insert(self.lineBufferIndex, ch)
else:
self.lineBuffer[self.lineBufferIndex : self.lineBufferIndex + 1] = [ch]
self.lineBufferIndex += 1
if moreCharactersComing:
# Skip it all, we'll get called with another character in
# like 2 femtoseconds.
return
if ch == b" ":
# Don't bother to try to color whitespace
self.terminal.write(ch)
return
source = self.getSource()
# Try to write some junk
try:
coloredLine = lastColorizedLine(source)
except tokenize.TokenError:
# We couldn't do it. Strange. Oh well, just add the character.
self.terminal.write(ch)
else:
# Success! Clear the source on this line.
self.terminal.eraseLine()
self.terminal.cursorBackward(
len(self.lineBuffer) + len(self.ps[self.pn]) - 1
)
# And write a new, colorized one.
self.terminal.write(self.ps[self.pn] + coloredLine)
# And move the cursor to where it belongs
n = len(self.lineBuffer) - self.lineBufferIndex
if n:
self.terminal.cursorBackward(n)

View File

@@ -0,0 +1,148 @@
# -*- test-case-name: twisted.conch.test.test_manhole -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
insults/SSH integration support.
@author: Jp Calderone
"""
from typing import Dict
from zope.interface import implementer
from twisted.conch import avatar, error as econch, interfaces as iconch
from twisted.conch.insults import insults
from twisted.conch.ssh import factory, session
from twisted.python import components
class _Glue:
"""
A feeble class for making one attribute look like another.
This should be replaced with a real class at some point, probably.
Try not to write new code that uses it.
"""
def __init__(self, **kw):
self.__dict__.update(kw)
def __getattr__(self, name):
raise AttributeError(self.name, "has no attribute", name)
class TerminalSessionTransport:
def __init__(self, proto, chainedProtocol, avatar, width, height):
self.proto = proto
self.avatar = avatar
self.chainedProtocol = chainedProtocol
protoSession = self.proto.session
self.proto.makeConnection(
_Glue(
write=self.chainedProtocol.dataReceived,
loseConnection=lambda: avatar.conn.sendClose(protoSession),
name="SSH Proto Transport",
)
)
def loseConnection():
self.proto.loseConnection()
self.chainedProtocol.makeConnection(
_Glue(
write=self.proto.write,
loseConnection=loseConnection,
name="Chained Proto Transport",
)
)
# XXX TODO
# chainedProtocol is supposed to be an ITerminalTransport,
# maybe. That means perhaps its terminalProtocol attribute is
# an ITerminalProtocol, it could be. So calling terminalSize
# on that should do the right thing But it'd be nice to clean
# this bit up.
self.chainedProtocol.terminalProtocol.terminalSize(width, height)
@implementer(iconch.ISession)
class TerminalSession(components.Adapter):
transportFactory = TerminalSessionTransport
chainedProtocolFactory = insults.ServerProtocol
def getPty(self, term, windowSize, attrs):
self.height, self.width = windowSize[:2]
def openShell(self, proto):
self.transportFactory(
proto,
self.chainedProtocolFactory(),
iconch.IConchUser(self.original),
self.width,
self.height,
)
def execCommand(self, proto, cmd):
raise econch.ConchError("Cannot execute commands")
def windowChanged(self, newWindowSize):
# ISession.windowChanged
raise NotImplementedError("Unimplemented: TerminalSession.windowChanged")
def eofReceived(self):
# ISession.eofReceived
raise NotImplementedError("Unimplemented: TerminalSession.eofReceived")
def closed(self):
# ISession.closed
pass
class TerminalUser(avatar.ConchUser, components.Adapter):
def __init__(self, original, avatarId):
components.Adapter.__init__(self, original)
avatar.ConchUser.__init__(self)
self.channelLookup[b"session"] = session.SSHSession
class TerminalRealm:
userFactory = TerminalUser
sessionFactory = TerminalSession
transportFactory = TerminalSessionTransport
chainedProtocolFactory = insults.ServerProtocol
def _getAvatar(self, avatarId):
comp = components.Componentized()
user = self.userFactory(comp, avatarId)
sess = self.sessionFactory(comp)
sess.transportFactory = self.transportFactory
sess.chainedProtocolFactory = self.chainedProtocolFactory
comp.setComponent(iconch.IConchUser, user)
comp.setComponent(iconch.ISession, sess)
return user
def __init__(self, transportFactory=None):
if transportFactory is not None:
self.transportFactory = transportFactory
def requestAvatar(self, avatarId, mind, *interfaces):
for i in interfaces:
if i is iconch.IConchUser:
return (iconch.IConchUser, self._getAvatar(avatarId), lambda: None)
raise NotImplementedError()
class ConchFactory(factory.SSHFactory):
publicKeys: Dict[bytes, bytes] = {}
privateKeys: Dict[bytes, bytes] = {}
def __init__(self, portal):
self.portal = portal

View File

@@ -0,0 +1,180 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
TAP plugin for creating telnet- and ssh-accessible manhole servers.
@author: Jp Calderone
"""
from zope.interface import implementer
from twisted.application import service, strports
from twisted.conch import manhole, manhole_ssh, telnet
from twisted.conch.insults import insults
from twisted.conch.ssh import keys
from twisted.cred import checkers, portal
from twisted.internet import protocol
from twisted.python import filepath, usage
class makeTelnetProtocol:
def __init__(self, portal):
self.portal = portal
def __call__(self):
auth = telnet.AuthenticatingTelnetProtocol
args = (self.portal,)
return telnet.TelnetTransport(auth, *args)
class chainedProtocolFactory:
def __init__(self, namespace):
self.namespace = namespace
def __call__(self):
return insults.ServerProtocol(manhole.ColoredManhole, self.namespace)
@implementer(portal.IRealm)
class _StupidRealm:
def __init__(self, proto, *a, **kw):
self.protocolFactory = proto
self.protocolArgs = a
self.protocolKwArgs = kw
def requestAvatar(self, avatarId, *interfaces):
if telnet.ITelnetProtocol in interfaces:
return (
telnet.ITelnetProtocol,
self.protocolFactory(*self.protocolArgs, **self.protocolKwArgs),
lambda: None,
)
raise NotImplementedError()
class Options(usage.Options):
optParameters = [
[
"telnetPort",
"t",
None,
(
"strports description of the address on which to listen for telnet "
"connections"
),
],
[
"sshPort",
"s",
None,
(
"strports description of the address on which to listen for ssh "
"connections"
),
],
[
"passwd",
"p",
"/etc/passwd",
"name of a passwd(5)-format username/password file",
],
[
"sshKeyDir",
None,
"<USER DATA DIR>",
"Directory where the autogenerated SSH key is kept.",
],
["sshKeyName", None, "server.key", "Filename of the autogenerated SSH key."],
["sshKeySize", None, 4096, "Size of the automatically generated SSH key."],
]
def __init__(self):
usage.Options.__init__(self)
self["namespace"] = None
def postOptions(self):
if self["telnetPort"] is None and self["sshPort"] is None:
raise usage.UsageError(
"At least one of --telnetPort and --sshPort must be specified"
)
def makeService(options):
"""
Create a manhole server service.
@type options: L{dict}
@param options: A mapping describing the configuration of
the desired service. Recognized key/value pairs are::
"telnetPort": strports description of the address on which
to listen for telnet connections. If None,
no telnet service will be started.
"sshPort": strports description of the address on which to
listen for ssh connections. If None, no ssh
service will be started.
"namespace": dictionary containing desired initial locals
for manhole connections. If None, an empty
dictionary will be used.
"passwd": Name of a passwd(5)-format username/password file.
"sshKeyDir": The folder that the SSH server key will be kept in.
"sshKeyName": The filename of the key.
"sshKeySize": The size of the key, in bits. Default is 4096.
@rtype: L{twisted.application.service.IService}
@return: A manhole service.
"""
svc = service.MultiService()
namespace = options["namespace"]
if namespace is None:
namespace = {}
checker = checkers.FilePasswordDB(options["passwd"])
if options["telnetPort"]:
telnetRealm = _StupidRealm(
telnet.TelnetBootstrapProtocol,
insults.ServerProtocol,
manhole.ColoredManhole,
namespace,
)
telnetPortal = portal.Portal(telnetRealm, [checker])
telnetFactory = protocol.ServerFactory()
telnetFactory.protocol = makeTelnetProtocol(telnetPortal)
telnetService = strports.service(options["telnetPort"], telnetFactory)
telnetService.setServiceParent(svc)
if options["sshPort"]:
sshRealm = manhole_ssh.TerminalRealm()
sshRealm.chainedProtocolFactory = chainedProtocolFactory(namespace)
sshPortal = portal.Portal(sshRealm, [checker])
sshFactory = manhole_ssh.ConchFactory(sshPortal)
if options["sshKeyDir"] != "<USER DATA DIR>":
keyDir = options["sshKeyDir"]
else:
from twisted.python._appdirs import getDataDirectory
keyDir = getDataDirectory()
keyLocation = filepath.FilePath(keyDir).child(options["sshKeyName"])
sshKey = keys._getPersistentRSAKey(keyLocation, int(options["sshKeySize"]))
sshFactory.publicKeys[b"ssh-rsa"] = sshKey
sshFactory.privateKeys[b"ssh-rsa"] = sshKey
sshService = strports.service(options["sshPort"], sshFactory)
sshService.setServiceParent(svc)
return svc

View File

@@ -0,0 +1,54 @@
# -*- test-case-name: twisted.conch.test.test_mixin -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Experimental optimization
This module provides a single mixin class which allows protocols to
collapse numerous small writes into a single larger one.
@author: Jp Calderone
"""
from twisted.internet import reactor
class BufferingMixin:
"""
Mixin which adds write buffering.
"""
_delayedWriteCall = None
data = None
DELAY = 0.0
def schedule(self):
return reactor.callLater(self.DELAY, self.flush)
def reschedule(self, token):
token.reset(self.DELAY)
def write(self, data):
"""
Buffer some bytes to be written soon.
Every call to this function delays the real write by C{self.DELAY}
seconds. When the delay expires, all collected bytes are written
to the underlying transport using L{ITransport.writeSequence}.
"""
if self._delayedWriteCall is None:
self.data = []
self._delayedWriteCall = self.schedule()
else:
self.reschedule(self._delayedWriteCall)
self.data.append(data)
def flush(self):
"""
Flush the buffer immediately.
"""
self._delayedWriteCall = None
self.transport.writeSequence(self.data)
self.data = None

View File

@@ -0,0 +1 @@
!.gitignore

View File

@@ -0,0 +1,10 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
"""
Support for OpenSSH configuration files.
Maintainer: Paul Swartz
"""

View File

@@ -0,0 +1,74 @@
# -*- test-case-name: twisted.conch.test.test_openssh_compat -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Factory for reading openssh configuration files: public keys, private keys, and
moduli file.
"""
import errno
import os
from typing import Dict, List, Optional, Tuple
from twisted.conch.openssh_compat import primes
from twisted.conch.ssh import common, factory, keys
from twisted.python.util import runAsEffectiveUser
class OpenSSHFactory(factory.SSHFactory):
dataRoot = "/usr/local/etc"
# For openbsd which puts moduli in a different directory from keys.
moduliRoot = "/usr/local/etc"
def getPublicKeys(self):
"""
Return the server public keys.
"""
ks = {}
for filename in os.listdir(self.dataRoot):
if filename[:9] == "ssh_host_" and filename[-8:] == "_key.pub":
try:
k = keys.Key.fromFile(os.path.join(self.dataRoot, filename))
t = common.getNS(k.blob())[0]
ks[t] = k
except Exception as e:
self._log.error(
"bad public key file {filename}: {error}",
filename=filename,
error=e,
)
return ks
def getPrivateKeys(self):
"""
Return the server private keys.
"""
privateKeys = {}
for filename in os.listdir(self.dataRoot):
if filename[:9] == "ssh_host_" and filename[-4:] == "_key":
fullPath = os.path.join(self.dataRoot, filename)
try:
key = keys.Key.fromFile(fullPath)
except OSError as e:
if e.errno == errno.EACCES:
# Not allowed, let's switch to root
key = runAsEffectiveUser(0, 0, keys.Key.fromFile, fullPath)
privateKeys[key.sshType()] = key
else:
raise
except Exception as e:
self._log.error(
"bad public key file {filename}: {error}",
filename=filename,
error=e,
)
else:
privateKeys[key.sshType()] = key
return privateKeys
def getPrimes(self) -> Optional[Dict[int, List[Tuple[int, int]]]]:
try:
return primes.parseModuliFile(self.moduliRoot + "/moduli")
except OSError:
return None

View File

@@ -0,0 +1,31 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
"""
Parsing for the moduli file, which contains Diffie-Hellman prime groups.
Maintainer: Paul Swartz
"""
from typing import Dict, List, Tuple
def parseModuliFile(filename: str) -> Dict[int, List[Tuple[int, int]]]:
with open(filename) as f:
lines = f.readlines()
primes: Dict[int, List[Tuple[int, int]]] = {}
for l in lines:
l = l.strip()
if not l or l[0] == "#":
continue
tim, typ, tst, tri, sizestr, genstr, modstr = l.split()
size = int(sizestr) + 1
gen = int(genstr)
mod = int(modstr, 16)
if size not in primes:
primes[size] = []
primes[size].append((gen, mod))
return primes

View File

@@ -0,0 +1,569 @@
# -*- test-case-name: twisted.conch.test.test_recvline -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Basic line editing support.
@author: Jp Calderone
"""
import string
from typing import Dict
from zope.interface import implementer
from twisted.conch.insults import helper, insults
from twisted.logger import Logger
from twisted.python import reflect
from twisted.python.compat import iterbytes
_counters: Dict[str, int] = {}
class Logging:
"""
Wrapper which logs attribute lookups.
This was useful in debugging something, I guess. I forget what.
It can probably be deleted or moved somewhere more appropriate.
Nothing special going on here, really.
"""
def __init__(self, original):
self.original = original
key = reflect.qual(original.__class__)
count = _counters.get(key, 0)
_counters[key] = count + 1
self._logFile = open(key + "-" + str(count), "w")
def __str__(self) -> str:
return str(super().__getattribute__("original"))
def __repr__(self) -> str:
return repr(super().__getattribute__("original"))
def __getattribute__(self, name):
original = super().__getattribute__("original")
logFile = super().__getattribute__("_logFile")
logFile.write(name + "\n")
return getattr(original, name)
@implementer(insults.ITerminalTransport)
class TransportSequence:
"""
An L{ITerminalTransport} implementation which forwards calls to
one or more other L{ITerminalTransport}s.
This is a cheap way for servers to keep track of the state they
expect the client to see, since all terminal manipulations can be
send to the real client and to a terminal emulator that lives in
the server process.
"""
for keyID in (
b"UP_ARROW",
b"DOWN_ARROW",
b"RIGHT_ARROW",
b"LEFT_ARROW",
b"HOME",
b"INSERT",
b"DELETE",
b"END",
b"PGUP",
b"PGDN",
b"F1",
b"F2",
b"F3",
b"F4",
b"F5",
b"F6",
b"F7",
b"F8",
b"F9",
b"F10",
b"F11",
b"F12",
):
execBytes = keyID + b" = object()"
execStr = execBytes.decode("ascii")
exec(execStr)
TAB = b"\t"
BACKSPACE = b"\x7f"
def __init__(self, *transports):
assert transports, "Cannot construct a TransportSequence with no transports"
self.transports = transports
for method in insults.ITerminalTransport:
exec(
"""\
def %s(self, *a, **kw):
for tpt in self.transports:
result = tpt.%s(*a, **kw)
return result
"""
% (method, method)
)
def getHost(self):
# ITransport.getHost
raise NotImplementedError("Unimplemented: TransportSequence.getHost")
def getPeer(self):
# ITransport.getPeer
raise NotImplementedError("Unimplemented: TransportSequence.getPeer")
def loseConnection(self):
# ITransport.loseConnection
raise NotImplementedError("Unimplemented: TransportSequence.loseConnection")
def write(self, data):
# ITransport.write
raise NotImplementedError("Unimplemented: TransportSequence.write")
def writeSequence(self, data):
# ITransport.writeSequence
raise NotImplementedError("Unimplemented: TransportSequence.writeSequence")
def cursorUp(self, n=1):
# ITerminalTransport.cursorUp
raise NotImplementedError("Unimplemented: TransportSequence.cursorUp")
def cursorDown(self, n=1):
# ITerminalTransport.cursorDown
raise NotImplementedError("Unimplemented: TransportSequence.cursorDown")
def cursorForward(self, n=1):
# ITerminalTransport.cursorForward
raise NotImplementedError("Unimplemented: TransportSequence.cursorForward")
def cursorBackward(self, n=1):
# ITerminalTransport.cursorBackward
raise NotImplementedError("Unimplemented: TransportSequence.cursorBackward")
def cursorPosition(self, column, line):
# ITerminalTransport.cursorPosition
raise NotImplementedError("Unimplemented: TransportSequence.cursorPosition")
def cursorHome(self):
# ITerminalTransport.cursorHome
raise NotImplementedError("Unimplemented: TransportSequence.cursorHome")
def index(self):
# ITerminalTransport.index
raise NotImplementedError("Unimplemented: TransportSequence.index")
def reverseIndex(self):
# ITerminalTransport.reverseIndex
raise NotImplementedError("Unimplemented: TransportSequence.reverseIndex")
def nextLine(self):
# ITerminalTransport.nextLine
raise NotImplementedError("Unimplemented: TransportSequence.nextLine")
def saveCursor(self):
# ITerminalTransport.saveCursor
raise NotImplementedError("Unimplemented: TransportSequence.saveCursor")
def restoreCursor(self):
# ITerminalTransport.restoreCursor
raise NotImplementedError("Unimplemented: TransportSequence.restoreCursor")
def setModes(self, modes):
# ITerminalTransport.setModes
raise NotImplementedError("Unimplemented: TransportSequence.setModes")
def resetModes(self, mode):
# ITerminalTransport.resetModes
raise NotImplementedError("Unimplemented: TransportSequence.resetModes")
def setPrivateModes(self, modes):
# ITerminalTransport.setPrivateModes
raise NotImplementedError("Unimplemented: TransportSequence.setPrivateModes")
def resetPrivateModes(self, modes):
# ITerminalTransport.resetPrivateModes
raise NotImplementedError("Unimplemented: TransportSequence.resetPrivateModes")
def applicationKeypadMode(self):
# ITerminalTransport.applicationKeypadMode
raise NotImplementedError(
"Unimplemented: TransportSequence.applicationKeypadMode"
)
def numericKeypadMode(self):
# ITerminalTransport.numericKeypadMode
raise NotImplementedError("Unimplemented: TransportSequence.numericKeypadMode")
def selectCharacterSet(self, charSet, which):
# ITerminalTransport.selectCharacterSet
raise NotImplementedError("Unimplemented: TransportSequence.selectCharacterSet")
def shiftIn(self):
# ITerminalTransport.shiftIn
raise NotImplementedError("Unimplemented: TransportSequence.shiftIn")
def shiftOut(self):
# ITerminalTransport.shiftOut
raise NotImplementedError("Unimplemented: TransportSequence.shiftOut")
def singleShift2(self):
# ITerminalTransport.singleShift2
raise NotImplementedError("Unimplemented: TransportSequence.singleShift2")
def singleShift3(self):
# ITerminalTransport.singleShift3
raise NotImplementedError("Unimplemented: TransportSequence.singleShift3")
def selectGraphicRendition(self, *attributes):
# ITerminalTransport.selectGraphicRendition
raise NotImplementedError(
"Unimplemented: TransportSequence.selectGraphicRendition"
)
def horizontalTabulationSet(self):
# ITerminalTransport.horizontalTabulationSet
raise NotImplementedError(
"Unimplemented: TransportSequence.horizontalTabulationSet"
)
def tabulationClear(self):
# ITerminalTransport.tabulationClear
raise NotImplementedError("Unimplemented: TransportSequence.tabulationClear")
def tabulationClearAll(self):
# ITerminalTransport.tabulationClearAll
raise NotImplementedError("Unimplemented: TransportSequence.tabulationClearAll")
def doubleHeightLine(self, top=True):
# ITerminalTransport.doubleHeightLine
raise NotImplementedError("Unimplemented: TransportSequence.doubleHeightLine")
def singleWidthLine(self):
# ITerminalTransport.singleWidthLine
raise NotImplementedError("Unimplemented: TransportSequence.singleWidthLine")
def doubleWidthLine(self):
# ITerminalTransport.doubleWidthLine
raise NotImplementedError("Unimplemented: TransportSequence.doubleWidthLine")
def eraseToLineEnd(self):
# ITerminalTransport.eraseToLineEnd
raise NotImplementedError("Unimplemented: TransportSequence.eraseToLineEnd")
def eraseToLineBeginning(self):
# ITerminalTransport.eraseToLineBeginning
raise NotImplementedError(
"Unimplemented: TransportSequence.eraseToLineBeginning"
)
def eraseLine(self):
# ITerminalTransport.eraseLine
raise NotImplementedError("Unimplemented: TransportSequence.eraseLine")
def eraseToDisplayEnd(self):
# ITerminalTransport.eraseToDisplayEnd
raise NotImplementedError("Unimplemented: TransportSequence.eraseToDisplayEnd")
def eraseToDisplayBeginning(self):
# ITerminalTransport.eraseToDisplayBeginning
raise NotImplementedError(
"Unimplemented: TransportSequence.eraseToDisplayBeginning"
)
def eraseDisplay(self):
# ITerminalTransport.eraseDisplay
raise NotImplementedError("Unimplemented: TransportSequence.eraseDisplay")
def deleteCharacter(self, n=1):
# ITerminalTransport.deleteCharacter
raise NotImplementedError("Unimplemented: TransportSequence.deleteCharacter")
def insertLine(self, n=1):
# ITerminalTransport.insertLine
raise NotImplementedError("Unimplemented: TransportSequence.insertLine")
def deleteLine(self, n=1):
# ITerminalTransport.deleteLine
raise NotImplementedError("Unimplemented: TransportSequence.deleteLine")
def reportCursorPosition(self):
# ITerminalTransport.reportCursorPosition
raise NotImplementedError(
"Unimplemented: TransportSequence.reportCursorPosition"
)
def reset(self):
# ITerminalTransport.reset
raise NotImplementedError("Unimplemented: TransportSequence.reset")
def unhandledControlSequence(self, seq):
# ITerminalTransport.unhandledControlSequence
raise NotImplementedError(
"Unimplemented: TransportSequence.unhandledControlSequence"
)
class LocalTerminalBufferMixin:
"""
A mixin for RecvLine subclasses which records the state of the terminal.
This is accomplished by performing all L{ITerminalTransport} operations on both
the transport passed to makeConnection and an instance of helper.TerminalBuffer.
@ivar terminalCopy: A L{helper.TerminalBuffer} instance which efforts
will be made to keep up to date with the actual terminal
associated with this protocol instance.
"""
def makeConnection(self, transport):
self.terminalCopy = helper.TerminalBuffer()
self.terminalCopy.connectionMade()
return super().makeConnection(TransportSequence(transport, self.terminalCopy))
def __str__(self) -> str:
return str(self.terminalCopy)
class RecvLine(insults.TerminalProtocol):
"""
L{TerminalProtocol} which adds line editing features.
Clients will be prompted for lines of input with all the usual
features: character echoing, left and right arrow support for
moving the cursor to different areas of the line buffer, backspace
and delete for removing characters, and insert for toggling
between typeover and insert mode. Tabs will be expanded to enough
spaces to move the cursor to the next tabstop (every four
characters by default). Enter causes the line buffer to be
cleared and the line to be passed to the lineReceived() method
which, by default, does nothing. Subclasses are responsible for
redrawing the input prompt (this will probably change).
"""
width = 80
height = 24
TABSTOP = 4
ps = (b">>> ", b"... ")
pn = 0
_printableChars = string.printable.encode("ascii")
_log = Logger()
def connectionMade(self):
# A list containing the characters making up the current line
self.lineBuffer = []
# A zero-based (wtf else?) index into self.lineBuffer.
# Indicates the current cursor position.
self.lineBufferIndex = 0
t = self.terminal
# A map of keyIDs to bound instance methods.
self.keyHandlers = {
t.LEFT_ARROW: self.handle_LEFT,
t.RIGHT_ARROW: self.handle_RIGHT,
t.TAB: self.handle_TAB,
# Both of these should not be necessary, but figuring out
# which is necessary is a huge hassle.
b"\r": self.handle_RETURN,
b"\n": self.handle_RETURN,
t.BACKSPACE: self.handle_BACKSPACE,
t.DELETE: self.handle_DELETE,
t.INSERT: self.handle_INSERT,
t.HOME: self.handle_HOME,
t.END: self.handle_END,
}
self.initializeScreen()
def initializeScreen(self):
# Hmm, state sucks. Oh well.
# For now we will just take over the whole terminal.
self.terminal.reset()
self.terminal.write(self.ps[self.pn])
# XXX Note: I would prefer to default to starting in insert
# mode, however this does not seem to actually work! I do not
# know why. This is probably of interest to implementors
# subclassing RecvLine.
# XXX XXX Note: But the unit tests all expect the initial mode
# to be insert right now. Fuck, there needs to be a way to
# query the current mode or something.
# self.setTypeoverMode()
self.setInsertMode()
def currentLineBuffer(self):
s = b"".join(self.lineBuffer)
return s[: self.lineBufferIndex], s[self.lineBufferIndex :]
def setInsertMode(self):
self.mode = "insert"
self.terminal.setModes([insults.modes.IRM])
def setTypeoverMode(self):
self.mode = "typeover"
self.terminal.resetModes([insults.modes.IRM])
def drawInputLine(self):
"""
Write a line containing the current input prompt and the current line
buffer at the current cursor position.
"""
self.terminal.write(self.ps[self.pn] + b"".join(self.lineBuffer))
def terminalSize(self, width, height):
# XXX - Clear the previous input line, redraw it at the new
# cursor position
self.terminal.eraseDisplay()
self.terminal.cursorHome()
self.width = width
self.height = height
self.drawInputLine()
def unhandledControlSequence(self, seq):
pass
def keystrokeReceived(self, keyID, modifier):
m = self.keyHandlers.get(keyID)
if m is not None:
m()
elif keyID in self._printableChars:
self.characterReceived(keyID, False)
else:
self._log.warn("Received unhandled keyID: {keyID!r}", keyID=keyID)
def characterReceived(self, ch, moreCharactersComing):
if self.mode == "insert":
self.lineBuffer.insert(self.lineBufferIndex, ch)
else:
self.lineBuffer[self.lineBufferIndex : self.lineBufferIndex + 1] = [ch]
self.lineBufferIndex += 1
self.terminal.write(ch)
def handle_TAB(self):
n = self.TABSTOP - (len(self.lineBuffer) % self.TABSTOP)
self.terminal.cursorForward(n)
self.lineBufferIndex += n
self.lineBuffer.extend(iterbytes(b" " * n))
def handle_LEFT(self):
if self.lineBufferIndex > 0:
self.lineBufferIndex -= 1
self.terminal.cursorBackward()
def handle_RIGHT(self):
if self.lineBufferIndex < len(self.lineBuffer):
self.lineBufferIndex += 1
self.terminal.cursorForward()
def handle_HOME(self):
if self.lineBufferIndex:
self.terminal.cursorBackward(self.lineBufferIndex)
self.lineBufferIndex = 0
def handle_END(self):
offset = len(self.lineBuffer) - self.lineBufferIndex
if offset:
self.terminal.cursorForward(offset)
self.lineBufferIndex = len(self.lineBuffer)
def handle_BACKSPACE(self):
if self.lineBufferIndex > 0:
self.lineBufferIndex -= 1
del self.lineBuffer[self.lineBufferIndex]
self.terminal.cursorBackward()
self.terminal.deleteCharacter()
def handle_DELETE(self):
if self.lineBufferIndex < len(self.lineBuffer):
del self.lineBuffer[self.lineBufferIndex]
self.terminal.deleteCharacter()
def handle_RETURN(self):
line = b"".join(self.lineBuffer)
self.lineBuffer = []
self.lineBufferIndex = 0
self.terminal.nextLine()
self.lineReceived(line)
def handle_INSERT(self):
assert self.mode in ("typeover", "insert")
if self.mode == "typeover":
self.setInsertMode()
else:
self.setTypeoverMode()
def lineReceived(self, line):
pass
class HistoricRecvLine(RecvLine):
"""
L{TerminalProtocol} which adds both basic line-editing features and input history.
Everything supported by L{RecvLine} is also supported by this class. In addition, the
up and down arrows traverse the input history. Each received line is automatically
added to the end of the input history.
"""
def connectionMade(self):
RecvLine.connectionMade(self)
self.historyLines = []
self.historyPosition = 0
t = self.terminal
self.keyHandlers.update(
{t.UP_ARROW: self.handle_UP, t.DOWN_ARROW: self.handle_DOWN}
)
def currentHistoryBuffer(self):
b = tuple(self.historyLines)
return b[: self.historyPosition], b[self.historyPosition :]
def _deliverBuffer(self, buf):
if buf:
for ch in iterbytes(buf[:-1]):
self.characterReceived(ch, True)
self.characterReceived(buf[-1:], False)
def handle_UP(self):
if self.lineBuffer and self.historyPosition == len(self.historyLines):
self.historyLines.append(b"".join(self.lineBuffer))
if self.historyPosition > 0:
self.handle_HOME()
self.terminal.eraseToLineEnd()
self.historyPosition -= 1
self.lineBuffer = []
self._deliverBuffer(self.historyLines[self.historyPosition])
def handle_DOWN(self):
if self.historyPosition < len(self.historyLines) - 1:
self.handle_HOME()
self.terminal.eraseToLineEnd()
self.historyPosition += 1
self.lineBuffer = []
self._deliverBuffer(self.historyLines[self.historyPosition])
else:
self.handle_HOME()
self.terminal.eraseToLineEnd()
self.historyPosition = len(self.historyLines)
self.lineBuffer = []
self.lineBufferIndex = 0
def handle_RETURN(self):
if self.lineBuffer:
self.historyLines.append(b"".join(self.lineBuffer))
self.historyPosition = len(self.historyLines)
return RecvLine.handle_RETURN(self)

View File

@@ -0,0 +1 @@
"conch scripts"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,400 @@
# -*- test-case-name: twisted.conch.test.test_ckeygen -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Implementation module for the `ckeygen` command.
"""
from __future__ import annotations
import getpass
import os
import platform
import socket
import sys
from collections.abc import Callable
from functools import wraps
from importlib import reload
from typing import Any, Dict, Optional
from twisted.conch.ssh import keys
from twisted.python import failure, filepath, log, usage
if getpass.getpass == getpass.unix_getpass: # type: ignore[attr-defined]
try:
import termios # hack around broken termios
termios.tcgetattr, termios.tcsetattr
except (ImportError, AttributeError):
sys.modules["termios"] = None # type: ignore[assignment]
reload(getpass)
supportedKeyTypes = dict()
def _keyGenerator(keyType):
def assignkeygenerator(keygenerator):
@wraps(keygenerator)
def wrapper(*args, **kwargs):
return keygenerator(*args, **kwargs)
supportedKeyTypes[keyType] = wrapper
return wrapper
return assignkeygenerator
class GeneralOptions(usage.Options):
synopsis = """Usage: ckeygen [options]
"""
longdesc = "ckeygen manipulates public/private keys in various ways."
optParameters = [
["bits", "b", None, "Number of bits in the key to create."],
["filename", "f", None, "Filename of the key file."],
["type", "t", None, "Specify type of key to create."],
["comment", "C", None, "Provide new comment."],
["newpass", "N", None, "Provide new passphrase."],
["pass", "P", None, "Provide old passphrase."],
["format", "o", "sha256-base64", "Fingerprint format of key file."],
[
"private-key-subtype",
None,
None,
'OpenSSH private key subtype to write ("PEM" or "v1").',
],
]
optFlags = [
["fingerprint", "l", "Show fingerprint of key file."],
["changepass", "p", "Change passphrase of private key file."],
["quiet", "q", "Quiet."],
["no-passphrase", None, "Create the key with no passphrase."],
["showpub", "y", "Read private key file and print public key."],
]
compData = usage.Completions(
optActions={
"type": usage.CompleteList(list(supportedKeyTypes.keys())),
"private-key-subtype": usage.CompleteList(["PEM", "v1"]),
}
)
def run():
options = GeneralOptions()
try:
options.parseOptions(sys.argv[1:])
except usage.UsageError as u:
print("ERROR: %s" % u)
options.opt_help()
sys.exit(1)
log.discardLogs()
log.deferr = handleError # HACK
if options["type"]:
if options["type"].lower() in supportedKeyTypes:
print("Generating public/private %s key pair." % (options["type"]))
supportedKeyTypes[options["type"].lower()](options)
else:
sys.exit(
"Key type was %s, must be one of %s"
% (options["type"], ", ".join(supportedKeyTypes.keys()))
)
elif options["fingerprint"]:
printFingerprint(options)
elif options["changepass"]:
changePassPhrase(options)
elif options["showpub"]:
displayPublicKey(options)
else:
options.opt_help()
sys.exit(1)
def enumrepresentation(options):
if options["format"] == "md5-hex":
options["format"] = keys.FingerprintFormats.MD5_HEX
return options
elif options["format"] == "sha256-base64":
options["format"] = keys.FingerprintFormats.SHA256_BASE64
return options
else:
raise keys.BadFingerPrintFormat(
f"Unsupported fingerprint format: {options['format']}"
)
def handleError():
global exitStatus
exitStatus = 2
log.err(failure.Failure())
raise
@_keyGenerator("rsa")
def generateRSAkey(options):
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
if not options["bits"]:
options["bits"] = 2048
keyPrimitive = rsa.generate_private_key(
key_size=int(options["bits"]),
public_exponent=65537,
backend=default_backend(),
)
key = keys.Key(keyPrimitive)
_saveKey(key, options)
@_keyGenerator("dsa")
def generateDSAkey(options):
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import dsa
if not options["bits"]:
options["bits"] = 1024
keyPrimitive = dsa.generate_private_key(
key_size=int(options["bits"]),
backend=default_backend(),
)
key = keys.Key(keyPrimitive)
_saveKey(key, options)
@_keyGenerator("ecdsa")
def generateECDSAkey(options):
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
if not options["bits"]:
options["bits"] = 256
# OpenSSH supports only mandatory sections of RFC5656.
# See https://www.openssh.com/txt/release-5.7
curve = b"ecdsa-sha2-nistp" + str(options["bits"]).encode("ascii")
keyPrimitive = ec.generate_private_key(
curve=keys._curveTable[curve], backend=default_backend()
)
key = keys.Key(keyPrimitive)
_saveKey(key, options)
@_keyGenerator("ed25519")
def generateEd25519key(options):
keyPrimitive = keys.Ed25519PrivateKey.generate()
key = keys.Key(keyPrimitive)
_saveKey(key, options)
def _defaultPrivateKeySubtype(keyType):
"""
Return a reasonable default private key subtype for a given key type.
@type keyType: L{str}
@param keyType: A key type, as returned by
L{twisted.conch.ssh.keys.Key.type}.
@rtype: L{str}
@return: A private OpenSSH key subtype (C{'PEM'} or C{'v1'}).
"""
if keyType == "Ed25519":
# No PEM format is defined for Ed25519 keys.
return "v1"
else:
return "PEM"
def _getKeyOrDefault(
options: Dict[Any, Any],
inputCollector: Optional[Callable[[str], str]] = None,
keyTypeName: str = "rsa",
) -> str:
"""
If C{options["filename"]} is None, prompt the user to enter a path
or attempt to set it to .ssh/id_rsa
@param options: command line options
@param inputCollector: dependency injection for testing
@param keyTypeName: key type or "rsa"
"""
if inputCollector is None:
inputCollector = input
filename = options["filename"]
if not filename:
filename = os.path.expanduser(f"~/.ssh/id_{keyTypeName}")
if platform.system() == "Windows":
filename = os.path.expanduser(Rf"%HOMEPATH %\.ssh\id_{keyTypeName}")
filename = (
inputCollector("Enter file in which the key is (%s): " % filename)
or filename
)
return str(filename)
def printFingerprint(options: Dict[Any, Any]) -> None:
filename = _getKeyOrDefault(options)
if os.path.exists(filename + ".pub"):
filename += ".pub"
options = enumrepresentation(options)
try:
key = keys.Key.fromFile(filename)
print(
"%s %s %s"
% (
key.size(),
key.fingerprint(options["format"]),
os.path.basename(filename),
)
)
except keys.BadKeyError:
sys.exit("bad key")
except FileNotFoundError:
sys.exit(f"{filename} could not be opened, please specify a file.")
def changePassPhrase(options):
filename = _getKeyOrDefault(options)
try:
key = keys.Key.fromFile(filename)
except keys.EncryptedKeyError:
# Raised if password not supplied for an encrypted key
if not options.get("pass"):
options["pass"] = getpass.getpass("Enter old passphrase: ")
try:
key = keys.Key.fromFile(filename, passphrase=options["pass"])
except keys.BadKeyError:
sys.exit("Could not change passphrase: old passphrase error")
except keys.EncryptedKeyError as e:
sys.exit(f"Could not change passphrase: {e}")
except keys.BadKeyError as e:
sys.exit(f"Could not change passphrase: {e}")
except FileNotFoundError:
sys.exit(f"{filename} could not be opened, please specify a file.")
if not options.get("newpass"):
while 1:
p1 = getpass.getpass("Enter new passphrase (empty for no passphrase): ")
p2 = getpass.getpass("Enter same passphrase again: ")
if p1 == p2:
break
print("Passphrases do not match. Try again.")
options["newpass"] = p1
if options.get("private-key-subtype") is None:
options["private-key-subtype"] = _defaultPrivateKeySubtype(key.type())
try:
newkeydata = key.toString(
"openssh",
subtype=options["private-key-subtype"],
passphrase=options["newpass"],
)
except Exception as e:
sys.exit(f"Could not change passphrase: {e}")
try:
keys.Key.fromString(newkeydata, passphrase=options["newpass"])
except (keys.EncryptedKeyError, keys.BadKeyError) as e:
sys.exit(f"Could not change passphrase: {e}")
with open(filename, "wb") as fd:
fd.write(newkeydata)
print("Your identification has been saved with the new passphrase.")
def displayPublicKey(options):
filename = _getKeyOrDefault(options)
try:
key = keys.Key.fromFile(filename)
except FileNotFoundError:
sys.exit(f"{filename} could not be opened, please specify a file.")
except keys.EncryptedKeyError:
if not options.get("pass"):
options["pass"] = getpass.getpass("Enter passphrase: ")
key = keys.Key.fromFile(filename, passphrase=options["pass"])
displayKey = key.public().toString("openssh").decode("ascii")
print(displayKey)
def _inputSaveFile(prompt: str) -> str:
"""
Ask the user where to save the key.
This needs to be a separate function so the unit test can patch it.
"""
return input(prompt)
def _saveKey(
key: keys.Key,
options: Dict[Any, Any],
inputCollector: Optional[Callable[[str], str]] = None,
) -> None:
"""
Persist a SSH key on local filesystem.
@param key: Key which is persisted on local filesystem.
@param options:
@param inputCollector: Dependency injection for testing.
"""
if inputCollector is None:
inputCollector = input
KeyTypeMapping = {"EC": "ecdsa", "Ed25519": "ed25519", "RSA": "rsa", "DSA": "dsa"}
keyTypeName = KeyTypeMapping[key.type()]
filename = options["filename"]
if not filename:
defaultPath = _getKeyOrDefault(options, inputCollector, keyTypeName)
newPath = _inputSaveFile(
f"Enter file in which to save the key ({defaultPath}): "
)
filename = newPath.strip() or defaultPath
if os.path.exists(filename):
print(f"{filename} already exists.")
yn = inputCollector("Overwrite (y/n)? ")
if yn[0].lower() != "y":
sys.exit()
if options.get("no-passphrase"):
options["pass"] = b""
elif not options["pass"]:
while 1:
p1 = getpass.getpass("Enter passphrase (empty for no passphrase): ")
p2 = getpass.getpass("Enter same passphrase again: ")
if p1 == p2:
break
print("Passphrases do not match. Try again.")
options["pass"] = p1
if options.get("private-key-subtype") is None:
options["private-key-subtype"] = _defaultPrivateKeySubtype(key.type())
comment = f"{getpass.getuser()}@{socket.gethostname()}"
fp = filepath.FilePath(filename)
fp.setContent(
key.toString(
"openssh",
subtype=options["private-key-subtype"],
passphrase=options["pass"],
)
)
fp.chmod(0o100600)
filepath.FilePath(filename + ".pub").setContent(
key.public().toString("openssh", comment=comment)
)
options = enumrepresentation(options)
print(f"Your identification has been saved in {filename}")
print(f"Your public key has been saved in {filename}.pub")
print(f"The key fingerprint in {options['format']} is:")
print(key.fingerprint(options["format"]))
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,578 @@
# -*- test-case-name: twisted.conch.test.test_conch -*-
#
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
# $Id: conch.py,v 1.65 2004/03/11 00:29:14 z3p Exp $
# Implementation module for the `conch` command.
#
import fcntl
import getpass
import os
import signal
import struct
import sys
import tty
from typing import List, Tuple
from twisted.conch.client import connect, default
from twisted.conch.client.options import ConchOptions
from twisted.conch.error import ConchError
from twisted.conch.ssh import channel, common, connection, forwarding, session
from twisted.internet import reactor, stdio, task
from twisted.python import log, usage
from twisted.python.compat import ioType, networkString
class ClientOptions(ConchOptions):
synopsis = """Usage: conch [options] host [command]
"""
longdesc = (
"conch is a SSHv2 client that allows logging into a remote "
"machine and executing commands."
)
optParameters = [
["escape", "e", "~"],
[
"localforward",
"L",
None,
"listen-port:host:port Forward local port to remote address",
],
[
"remoteforward",
"R",
None,
"listen-port:host:port Forward remote port to local address",
],
]
optFlags = [
["null", "n", "Redirect input from /dev/null."],
["fork", "f", "Fork to background after authentication."],
["tty", "t", "Tty; allocate a tty even if command is given."],
["notty", "T", "Do not allocate a tty."],
["noshell", "N", "Do not execute a shell or command."],
["subsystem", "s", "Invoke command (mandatory) as SSH2 subsystem."],
]
compData = usage.Completions(
mutuallyExclusive=[("tty", "notty")],
optActions={
"localforward": usage.Completer(descr="listen-port:host:port"),
"remoteforward": usage.Completer(descr="listen-port:host:port"),
},
extraActions=[
usage.CompleteUserAtHost(),
usage.Completer(descr="command"),
usage.Completer(descr="argument", repeat=True),
],
)
localForwards: List[Tuple[int, Tuple[int, int]]] = []
remoteForwards: List[Tuple[int, Tuple[int, int]]] = []
def opt_escape(self, esc):
"""
Set escape character; ``none'' = disable
"""
if esc == "none":
self["escape"] = None
elif esc[0] == "^" and len(esc) == 2:
self["escape"] = chr(ord(esc[1]) - 64)
elif len(esc) == 1:
self["escape"] = esc
else:
sys.exit(f"Bad escape character '{esc}'.")
def opt_localforward(self, f):
"""
Forward local port to remote address (lport:host:port)
"""
localPort, remoteHost, remotePort = f.split(":") # Doesn't do v6 yet
localPort = int(localPort)
remotePort = int(remotePort)
self.localForwards.append((localPort, (remoteHost, remotePort)))
def opt_remoteforward(self, f):
"""
Forward remote port to local address (rport:host:port)
"""
remotePort, connHost, connPort = f.split(":") # Doesn't do v6 yet
remotePort = int(remotePort)
connPort = int(connPort)
self.remoteForwards.append((remotePort, (connHost, connPort)))
def parseArgs(self, host, *command):
self["host"] = host
self["command"] = " ".join(command)
# Rest of code in "run"
options = None
conn = None
exitStatus = 0
old = None
_inRawMode = 0
_savedRawMode = None
def run():
global options, old
args = sys.argv[1:]
if "-l" in args: # CVS is an idiot
i = args.index("-l")
args = args[i : i + 2] + args
del args[i + 2 : i + 4]
for arg in args[:]:
try:
i = args.index(arg)
if arg[:2] == "-o" and args[i + 1][0] != "-":
args[i : i + 2] = [] # Suck on it scp
except ValueError:
pass
options = ClientOptions()
try:
options.parseOptions(args)
except usage.UsageError as u:
print(f"ERROR: {u}")
options.opt_help()
sys.exit(1)
if options["log"]:
if options["logfile"]:
if options["logfile"] == "-":
f = sys.stdout
else:
f = open(options["logfile"], "a+")
else:
f = sys.stderr
realout = sys.stdout
log.startLogging(f)
sys.stdout = realout
else:
log.discardLogs()
doConnect()
fd = sys.stdin.fileno()
try:
old = tty.tcgetattr(fd)
except BaseException:
old = None
try:
oldUSR1 = signal.signal(
signal.SIGUSR1, lambda *a: reactor.callLater(0, reConnect)
)
except BaseException:
oldUSR1 = None
try:
reactor.run()
finally:
if old:
tty.tcsetattr(fd, tty.TCSANOW, old)
if oldUSR1:
signal.signal(signal.SIGUSR1, oldUSR1)
if (options["command"] and options["tty"]) or not options["notty"]:
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
if sys.stdout.isatty() and not options["command"]:
print("Connection to {} closed.".format(options["host"]))
sys.exit(exitStatus)
def handleError():
from twisted.python import failure
global exitStatus
exitStatus = 2
reactor.callLater(0.01, _stopReactor)
log.err(failure.Failure())
raise
def _stopReactor():
try:
reactor.stop()
except BaseException:
pass
def doConnect():
if "@" in options["host"]:
options["user"], options["host"] = options["host"].split("@", 1)
if not options.identitys:
options.identitys = ["~/.ssh/id_rsa", "~/.ssh/id_dsa"]
host = options["host"]
if not options["user"]:
options["user"] = getpass.getuser()
if not options["port"]:
options["port"] = 22
else:
options["port"] = int(options["port"])
host = options["host"]
port = options["port"]
vhk = default.verifyHostKey
if not options["host-key-algorithms"]:
options["host-key-algorithms"] = default.getHostKeyAlgorithms(host, options)
uao = default.SSHUserAuthClient(options["user"], options, SSHConnection())
connect.connect(host, port, options, vhk, uao).addErrback(_ebExit)
def _ebExit(f):
global exitStatus
exitStatus = f"conch: exiting with error {f}"
reactor.callLater(0.1, _stopReactor)
def onConnect():
# if keyAgent and options['agent']:
# cc = protocol.ClientCreator(reactor, SSHAgentForwardingLocal, conn)
# cc.connectUNIX(os.environ['SSH_AUTH_SOCK'])
if hasattr(conn.transport, "sendIgnore"):
_KeepAlive(conn)
if options.localForwards:
for localPort, hostport in options.localForwards:
s = reactor.listenTCP(
localPort,
forwarding.SSHListenForwardingFactory(
conn, hostport, SSHListenClientForwardingChannel
),
)
conn.localForwards.append(s)
if options.remoteForwards:
for remotePort, hostport in options.remoteForwards:
log.msg(f"asking for remote forwarding for {remotePort}:{hostport}")
conn.requestRemoteForwarding(remotePort, hostport)
reactor.addSystemEventTrigger("before", "shutdown", beforeShutdown)
if not options["noshell"] or options["agent"]:
conn.openChannel(SSHSession())
if options["fork"]:
if os.fork():
os._exit(0)
os.setsid()
for i in range(3):
try:
os.close(i)
except OSError as e:
import errno
if e.errno != errno.EBADF:
raise
def reConnect():
beforeShutdown()
conn.transport.transport.loseConnection()
def beforeShutdown():
remoteForwards = options.remoteForwards
for remotePort, hostport in remoteForwards:
log.msg(f"cancelling {remotePort}:{hostport}")
conn.cancelRemoteForwarding(remotePort)
def stopConnection():
if not options["reconnect"]:
reactor.callLater(0.1, _stopReactor)
class _KeepAlive:
def __init__(self, conn):
self.conn = conn
self.globalTimeout = None
self.lc = task.LoopingCall(self.sendGlobal)
self.lc.start(300)
def sendGlobal(self):
d = self.conn.sendGlobalRequest(
b"conch-keep-alive@twistedmatrix.com", b"", wantReply=1
)
d.addBoth(self._cbGlobal)
self.globalTimeout = reactor.callLater(30, self._ebGlobal)
def _cbGlobal(self, res):
if self.globalTimeout:
self.globalTimeout.cancel()
self.globalTimeout = None
def _ebGlobal(self):
if self.globalTimeout:
self.globalTimeout = None
self.conn.transport.loseConnection()
class SSHConnection(connection.SSHConnection):
def serviceStarted(self):
global conn
conn = self
self.localForwards = []
self.remoteForwards = {}
onConnect()
def serviceStopped(self):
lf = self.localForwards
self.localForwards = []
for s in lf:
s.loseConnection()
stopConnection()
def requestRemoteForwarding(self, remotePort, hostport):
data = forwarding.packGlobal_tcpip_forward(("0.0.0.0", remotePort))
d = self.sendGlobalRequest(b"tcpip-forward", data, wantReply=1)
log.msg(f"requesting remote forwarding {remotePort}:{hostport}")
d.addCallback(self._cbRemoteForwarding, remotePort, hostport)
d.addErrback(self._ebRemoteForwarding, remotePort, hostport)
def _cbRemoteForwarding(self, result, remotePort, hostport):
log.msg(f"accepted remote forwarding {remotePort}:{hostport}")
self.remoteForwards[remotePort] = hostport
log.msg(repr(self.remoteForwards))
def _ebRemoteForwarding(self, f, remotePort, hostport):
log.msg(f"remote forwarding {remotePort}:{hostport} failed")
log.msg(f)
def cancelRemoteForwarding(self, remotePort):
data = forwarding.packGlobal_tcpip_forward(("0.0.0.0", remotePort))
self.sendGlobalRequest(b"cancel-tcpip-forward", data)
log.msg(f"cancelling remote forwarding {remotePort}")
try:
del self.remoteForwards[remotePort]
except Exception:
pass
log.msg(repr(self.remoteForwards))
def channel_forwarded_tcpip(self, windowSize, maxPacket, data):
log.msg(f"FTCP {data!r}")
remoteHP, origHP = forwarding.unpackOpen_forwarded_tcpip(data)
log.msg(self.remoteForwards)
log.msg(remoteHP)
if remoteHP[1] in self.remoteForwards:
connectHP = self.remoteForwards[remoteHP[1]]
log.msg(f"connect forwarding {connectHP}")
return SSHConnectForwardingChannel(
connectHP, remoteWindow=windowSize, remoteMaxPacket=maxPacket, conn=self
)
else:
raise ConchError(
connection.OPEN_CONNECT_FAILED, "don't know about that port"
)
def channelClosed(self, channel):
log.msg(f"connection closing {channel}")
log.msg(self.channels)
if len(self.channels) == 1: # Just us left
log.msg("stopping connection")
stopConnection()
else:
# Because of the unix thing
self.__class__.__bases__[0].channelClosed(self, channel)
class SSHSession(channel.SSHChannel):
name = b"session"
def channelOpen(self, foo):
log.msg(f"session {self.id} open")
if options["agent"]:
d = self.conn.sendRequest(
self, b"auth-agent-req@openssh.com", b"", wantReply=1
)
d.addBoth(lambda x: log.msg(x))
if options["noshell"]:
return
if (options["command"] and options["tty"]) or not options["notty"]:
_enterRawMode()
c = session.SSHSessionClient()
if options["escape"] and not options["notty"]:
self.escapeMode = 1
c.dataReceived = self.handleInput
else:
c.dataReceived = self.write
c.connectionLost = lambda x: self.sendEOF()
self.stdio = stdio.StandardIO(c)
fd = 0
if options["subsystem"]:
self.conn.sendRequest(self, b"subsystem", common.NS(options["command"]))
elif options["command"]:
if options["tty"]:
term = os.environ["TERM"]
winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, "12345678")
winSize = struct.unpack("4H", winsz)
ptyReqData = session.packRequest_pty_req(term, winSize, "")
self.conn.sendRequest(self, b"pty-req", ptyReqData)
signal.signal(signal.SIGWINCH, self._windowResized)
self.conn.sendRequest(self, b"exec", common.NS(options["command"]))
else:
if not options["notty"]:
term = os.environ["TERM"]
winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, "12345678")
winSize = struct.unpack("4H", winsz)
ptyReqData = session.packRequest_pty_req(term, winSize, "")
self.conn.sendRequest(self, b"pty-req", ptyReqData)
signal.signal(signal.SIGWINCH, self._windowResized)
self.conn.sendRequest(self, b"shell", b"")
# if hasattr(conn.transport, 'transport'):
# conn.transport.transport.setTcpNoDelay(1)
def handleInput(self, char):
if char in (b"\n", b"\r"):
self.escapeMode = 1
self.write(char)
elif self.escapeMode == 1 and char == options["escape"]:
self.escapeMode = 2
elif self.escapeMode == 2:
self.escapeMode = 1 # So we can chain escapes together
if char == b".": # Disconnect
log.msg("disconnecting from escape")
stopConnection()
return
elif char == b"\x1a": # ^Z, suspend
def _():
_leaveRawMode()
sys.stdout.flush()
sys.stdin.flush()
os.kill(os.getpid(), signal.SIGTSTP)
_enterRawMode()
reactor.callLater(0, _)
return
elif char == b"R": # Rekey connection
log.msg("rekeying connection")
self.conn.transport.sendKexInit()
return
elif char == b"#": # Display connections
self.stdio.write(b"\r\nThe following connections are open:\r\n")
channels = self.conn.channels.keys()
channels.sort()
for channelId in channels:
self.stdio.write(
networkString(
" #{} {}\r\n".format(
channelId, self.conn.channels[channelId]
)
)
)
return
self.write(b"~" + char)
else:
self.escapeMode = 0
self.write(char)
def dataReceived(self, data):
self.stdio.write(data)
def extReceived(self, t, data):
if t == connection.EXTENDED_DATA_STDERR:
log.msg(f"got {len(data)} stderr data")
if ioType(sys.stderr) == str:
sys.stderr.buffer.write(data)
else:
sys.stderr.write(data)
def eofReceived(self):
log.msg("got eof")
self.stdio.loseWriteConnection()
def closeReceived(self):
log.msg(f"remote side closed {self}")
self.conn.sendClose(self)
def closed(self):
global old
log.msg(f"closed {self}")
log.msg(repr(self.conn.channels))
def request_exit_status(self, data):
global exitStatus
exitStatus = int(struct.unpack(">L", data)[0])
log.msg(f"exit status: {exitStatus}")
def sendEOF(self):
self.conn.sendEOF(self)
def stopWriting(self):
self.stdio.pauseProducing()
def startWriting(self):
self.stdio.resumeProducing()
def _windowResized(self, *args):
winsz = fcntl.ioctl(0, tty.TIOCGWINSZ, "12345678")
winSize = struct.unpack("4H", winsz)
newSize = winSize[1], winSize[0], winSize[2], winSize[3]
self.conn.sendRequest(self, b"window-change", struct.pack("!4L", *newSize))
class SSHListenClientForwardingChannel(forwarding.SSHListenClientForwardingChannel):
pass
class SSHConnectForwardingChannel(forwarding.SSHConnectForwardingChannel):
pass
def _leaveRawMode():
global _inRawMode
if not _inRawMode:
return
fd = sys.stdin.fileno()
tty.tcsetattr(fd, tty.TCSANOW, _savedRawMode)
_inRawMode = 0
def _enterRawMode():
global _inRawMode, _savedRawMode
if _inRawMode:
return
fd = sys.stdin.fileno()
try:
old = tty.tcgetattr(fd)
new = old[:]
except BaseException:
log.msg("not a typewriter!")
else:
# iflage
new[0] = new[0] | tty.IGNPAR
new[0] = new[0] & ~(
tty.ISTRIP
| tty.INLCR
| tty.IGNCR
| tty.ICRNL
| tty.IXON
| tty.IXANY
| tty.IXOFF
)
if hasattr(tty, "IUCLC"):
new[0] = new[0] & ~tty.IUCLC
# lflag
new[3] = new[3] & ~(
tty.ISIG
| tty.ICANON
| tty.ECHO
| tty.ECHO
| tty.ECHOE
| tty.ECHOK
| tty.ECHONL
)
if hasattr(tty, "IEXTEN"):
new[3] = new[3] & ~tty.IEXTEN
# oflag
new[1] = new[1] & ~tty.OPOST
new[6][tty.VMIN] = 1
new[6][tty.VTIME] = 0
_savedRawMode = old
tty.tcsetattr(fd, tty.TCSANOW, new)
# tty.setraw(fd)
_inRawMode = 1
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,673 @@
# -*- test-case-name: twisted.conch.test.test_scripts -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Implementation module for the `tkconch` command.
"""
import base64
import getpass
import os
import signal
import struct
import sys
import tkinter as Tkinter
import tkinter.filedialog as tkFileDialog
import tkinter.messagebox as tkMessageBox
from typing import List, Tuple
from twisted.conch import error
from twisted.conch.client.default import isInKnownHosts
from twisted.conch.ssh import (
channel,
common,
connection,
forwarding,
keys,
session,
transport,
userauth,
)
from twisted.conch.ui import tkvt100
from twisted.internet import defer, protocol, reactor, tksupport
from twisted.python import log, usage
class TkConchMenu(Tkinter.Frame):
def __init__(self, *args, **params):
## Standard heading: initialization
Tkinter.Frame.__init__(self, *args, **params)
self.master.title("TkConch")
self.localRemoteVar = Tkinter.StringVar()
self.localRemoteVar.set("local")
Tkinter.Label(self, anchor="w", justify="left", text="Hostname").grid(
column=1, row=1, sticky="w"
)
self.host = Tkinter.Entry(self)
self.host.grid(column=2, columnspan=2, row=1, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="Port").grid(
column=1, row=2, sticky="w"
)
self.port = Tkinter.Entry(self)
self.port.grid(column=2, columnspan=2, row=2, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="Username").grid(
column=1, row=3, sticky="w"
)
self.user = Tkinter.Entry(self)
self.user.grid(column=2, columnspan=2, row=3, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="Command").grid(
column=1, row=4, sticky="w"
)
self.command = Tkinter.Entry(self)
self.command.grid(column=2, columnspan=2, row=4, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="Identity").grid(
column=1, row=5, sticky="w"
)
self.identity = Tkinter.Entry(self)
self.identity.grid(column=2, row=5, sticky="nesw")
Tkinter.Button(self, command=self.getIdentityFile, text="Browse").grid(
column=3, row=5, sticky="nesw"
)
Tkinter.Label(self, text="Port Forwarding").grid(column=1, row=6, sticky="w")
self.forwards = Tkinter.Listbox(self, height=0, width=0)
self.forwards.grid(column=2, columnspan=2, row=6, sticky="nesw")
Tkinter.Button(self, text="Add", command=self.addForward).grid(column=1, row=7)
Tkinter.Button(self, text="Remove", command=self.removeForward).grid(
column=1, row=8
)
self.forwardPort = Tkinter.Entry(self)
self.forwardPort.grid(column=2, row=7, sticky="nesw")
Tkinter.Label(self, text="Port").grid(column=3, row=7, sticky="nesw")
self.forwardHost = Tkinter.Entry(self)
self.forwardHost.grid(column=2, row=8, sticky="nesw")
Tkinter.Label(self, text="Host").grid(column=3, row=8, sticky="nesw")
self.localForward = Tkinter.Radiobutton(
self, text="Local", variable=self.localRemoteVar, value="local"
)
self.localForward.grid(column=2, row=9)
self.remoteForward = Tkinter.Radiobutton(
self, text="Remote", variable=self.localRemoteVar, value="remote"
)
self.remoteForward.grid(column=3, row=9)
Tkinter.Label(self, text="Advanced Options").grid(
column=1, columnspan=3, row=10, sticky="nesw"
)
Tkinter.Label(self, anchor="w", justify="left", text="Cipher").grid(
column=1, row=11, sticky="w"
)
self.cipher = Tkinter.Entry(self, name="cipher")
self.cipher.grid(column=2, columnspan=2, row=11, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="MAC").grid(
column=1, row=12, sticky="w"
)
self.mac = Tkinter.Entry(self, name="mac")
self.mac.grid(column=2, columnspan=2, row=12, sticky="nesw")
Tkinter.Label(self, anchor="w", justify="left", text="Escape Char").grid(
column=1, row=13, sticky="w"
)
self.escape = Tkinter.Entry(self, name="escape")
self.escape.grid(column=2, columnspan=2, row=13, sticky="nesw")
Tkinter.Button(self, text="Connect!", command=self.doConnect).grid(
column=1, columnspan=3, row=14, sticky="nesw"
)
# Resize behavior(s)
self.grid_rowconfigure(6, weight=1, minsize=64)
self.grid_columnconfigure(2, weight=1, minsize=2)
self.master.protocol("WM_DELETE_WINDOW", sys.exit)
def getIdentityFile(self):
r = tkFileDialog.askopenfilename()
if r:
self.identity.delete(0, Tkinter.END)
self.identity.insert(Tkinter.END, r)
def addForward(self):
port = self.forwardPort.get()
self.forwardPort.delete(0, Tkinter.END)
host = self.forwardHost.get()
self.forwardHost.delete(0, Tkinter.END)
if self.localRemoteVar.get() == "local":
self.forwards.insert(Tkinter.END, f"L:{port}:{host}")
else:
self.forwards.insert(Tkinter.END, f"R:{port}:{host}")
def removeForward(self):
cur = self.forwards.curselection()
if cur:
self.forwards.remove(cur[0])
def doConnect(self):
finished = 1
options["host"] = self.host.get()
options["port"] = self.port.get()
options["user"] = self.user.get()
options["command"] = self.command.get()
cipher = self.cipher.get()
mac = self.mac.get()
escape = self.escape.get()
if cipher:
if cipher in SSHClientTransport.supportedCiphers:
SSHClientTransport.supportedCiphers = [cipher]
else:
tkMessageBox.showerror("TkConch", "Bad cipher.")
finished = 0
if mac:
if mac in SSHClientTransport.supportedMACs:
SSHClientTransport.supportedMACs = [mac]
elif finished:
tkMessageBox.showerror("TkConch", "Bad MAC.")
finished = 0
if escape:
if escape == "none":
options["escape"] = None
elif escape[0] == "^" and len(escape) == 2:
options["escape"] = chr(ord(escape[1]) - 64)
elif len(escape) == 1:
options["escape"] = escape
elif finished:
tkMessageBox.showerror("TkConch", "Bad escape character '%s'." % escape)
finished = 0
if self.identity.get():
options.identitys.append(self.identity.get())
for line in self.forwards.get(0, Tkinter.END):
if line[0] == "L":
options.opt_localforward(line[2:])
else:
options.opt_remoteforward(line[2:])
if "@" in options["host"]:
options["user"], options["host"] = options["host"].split("@", 1)
if (not options["host"] or not options["user"]) and finished:
tkMessageBox.showerror("TkConch", "Missing host or username.")
finished = 0
if finished:
self.master.quit()
self.master.destroy()
if options["log"]:
realout = sys.stdout
log.startLogging(sys.stderr)
sys.stdout = realout
else:
log.discardLogs()
log.deferr = handleError # HACK
if not options.identitys:
options.identitys = ["~/.ssh/id_rsa", "~/.ssh/id_dsa"]
host = options["host"]
port = int(options["port"] or 22)
log.msg((host, port))
reactor.connectTCP(host, port, SSHClientFactory())
frame.master.deiconify()
frame.master.title(
"{}@{} - TkConch".format(options["user"], options["host"])
)
else:
self.focus()
class GeneralOptions(usage.Options):
synopsis = """Usage: tkconch [options] host [command]
"""
optParameters = [
["user", "l", None, "Log in using this user name."],
["identity", "i", "~/.ssh/identity", "Identity for public key authentication"],
["escape", "e", "~", "Set escape character; ``none'' = disable"],
["cipher", "c", None, "Select encryption algorithm."],
["macs", "m", None, "Specify MAC algorithms for protocol version 2."],
["port", "p", None, "Connect to this port. Server must be on the same port."],
[
"localforward",
"L",
None,
"listen-port:host:port Forward local port to remote address",
],
[
"remoteforward",
"R",
None,
"listen-port:host:port Forward remote port to local address",
],
]
optFlags = [
["tty", "t", "Tty; allocate a tty even if command is given."],
["notty", "T", "Do not allocate a tty."],
["version", "V", "Display version number only."],
["compress", "C", "Enable compression."],
["noshell", "N", "Do not execute a shell or command."],
["subsystem", "s", "Invoke command (mandatory) as SSH2 subsystem."],
["log", "v", "Log to stderr"],
["ansilog", "a", "Print the received data to stdout"],
]
_ciphers = transport.SSHClientTransport.supportedCiphers
_macs = transport.SSHClientTransport.supportedMACs
compData = usage.Completions(
mutuallyExclusive=[("tty", "notty")],
optActions={
"cipher": usage.CompleteList([v.decode() for v in _ciphers]),
"macs": usage.CompleteList([v.decode() for v in _macs]),
"localforward": usage.Completer(descr="listen-port:host:port"),
"remoteforward": usage.Completer(descr="listen-port:host:port"),
},
extraActions=[
usage.CompleteUserAtHost(),
usage.Completer(descr="command"),
usage.Completer(descr="argument", repeat=True),
],
)
identitys: List[str] = []
localForwards: List[Tuple[int, Tuple[int, int]]] = []
remoteForwards: List[Tuple[int, Tuple[int, int]]] = []
def opt_identity(self, i):
self.identitys.append(i)
def opt_localforward(self, f):
localPort, remoteHost, remotePort = f.split(":") # doesn't do v6 yet
localPort = int(localPort)
remotePort = int(remotePort)
self.localForwards.append((localPort, (remoteHost, remotePort)))
def opt_remoteforward(self, f):
remotePort, connHost, connPort = f.split(":") # doesn't do v6 yet
remotePort = int(remotePort)
connPort = int(connPort)
self.remoteForwards.append((remotePort, (connHost, connPort)))
def opt_compress(self):
SSHClientTransport.supportedCompressions[0:1] = ["zlib"]
def parseArgs(self, *args):
if args:
self["host"] = args[0]
self["command"] = " ".join(args[1:])
else:
self["host"] = ""
self["command"] = ""
# Rest of code in "run"
options = None
menu = None
exitStatus = 0
frame = None
def deferredAskFrame(question, echo):
if frame.callback:
raise ValueError("can't ask 2 questions at once!")
d = defer.Deferred()
resp = []
def gotChar(ch, resp=resp):
if not ch:
return
if ch == "\x03": # C-c
reactor.stop()
if ch == "\r":
frame.write("\r\n")
stresp = "".join(resp)
del resp
frame.callback = None
d.callback(stresp)
return
elif 32 <= ord(ch) < 127:
resp.append(ch)
if echo:
frame.write(ch)
elif ord(ch) == 8 and resp: # BS
if echo:
frame.write("\x08 \x08")
resp.pop()
frame.callback = gotChar
frame.write(question)
frame.canvas.focus_force()
return d
def run():
global menu, options, frame
args = sys.argv[1:]
if "-l" in args: # cvs is an idiot
i = args.index("-l")
args = args[i : i + 2] + args
del args[i + 2 : i + 4]
for arg in args[:]:
try:
i = args.index(arg)
if arg[:2] == "-o" and args[i + 1][0] != "-":
args[i : i + 2] = [] # suck on it scp
except ValueError:
pass
root = Tkinter.Tk()
root.withdraw()
top = Tkinter.Toplevel()
menu = TkConchMenu(top)
menu.pack(side=Tkinter.TOP, fill=Tkinter.BOTH, expand=1)
options = GeneralOptions()
try:
options.parseOptions(args)
except usage.UsageError as u:
print("ERROR: %s" % u)
options.opt_help()
sys.exit(1)
for k, v in options.items():
if v and hasattr(menu, k):
getattr(menu, k).insert(Tkinter.END, v)
for p, (rh, rp) in options.localForwards:
menu.forwards.insert(Tkinter.END, f"L:{p}:{rh}:{rp}")
options.localForwards = []
for p, (rh, rp) in options.remoteForwards:
menu.forwards.insert(Tkinter.END, f"R:{p}:{rh}:{rp}")
options.remoteForwards = []
frame = tkvt100.VT100Frame(root, callback=None)
root.geometry(
"%dx%d"
% (tkvt100.fontWidth * frame.width + 3, tkvt100.fontHeight * frame.height + 3)
)
frame.pack(side=Tkinter.TOP)
tksupport.install(root)
root.withdraw()
if (options["host"] and options["user"]) or "@" in options["host"]:
menu.doConnect()
else:
top.mainloop()
reactor.run()
sys.exit(exitStatus)
def handleError():
from twisted.python import failure
global exitStatus
exitStatus = 2
log.err(failure.Failure())
reactor.stop()
raise
class SSHClientFactory(protocol.ClientFactory):
noisy = True
def stopFactory(self):
reactor.stop()
def buildProtocol(self, addr):
return SSHClientTransport()
def clientConnectionFailed(self, connector, reason):
tkMessageBox.showwarning(
"TkConch",
f"Connection Failed, Reason:\n {reason.type}: {reason.value}",
)
class SSHClientTransport(transport.SSHClientTransport):
def receiveError(self, code, desc):
global exitStatus
exitStatus = (
"conch:\tRemote side disconnected with error code %i\nconch:\treason: %s"
% (code, desc)
)
def sendDisconnect(self, code, reason):
global exitStatus
exitStatus = (
"conch:\tSending disconnect with error code %i\nconch:\treason: %s"
% (code, reason)
)
transport.SSHClientTransport.sendDisconnect(self, code, reason)
def receiveDebug(self, alwaysDisplay, message, lang):
global options
if alwaysDisplay or options["log"]:
log.msg("Received Debug Message: %s" % message)
def verifyHostKey(self, pubKey, fingerprint):
# d = defer.Deferred()
# d.addCallback(lambda x:defer.succeed(1))
# d.callback(2)
# return d
goodKey = isInKnownHosts(options["host"], pubKey, {"known-hosts": None})
if goodKey == 1: # good key
return defer.succeed(1)
elif goodKey == 2: # AAHHHHH changed
return defer.fail(error.ConchError("bad host key"))
else:
if options["host"] == self.transport.getPeer().host:
host = options["host"]
khHost = options["host"]
else:
host = "{} ({})".format(options["host"], self.transport.getPeer().host)
khHost = "{},{}".format(options["host"], self.transport.getPeer().host)
keyType = common.getNS(pubKey)[0]
ques = """The authenticity of host '{}' can't be established.\r
{} key fingerprint is {}.""".format(
host,
{b"ssh-dss": "DSA", b"ssh-rsa": "RSA"}[keyType],
fingerprint,
)
ques += "\r\nAre you sure you want to continue connecting (yes/no)? "
return deferredAskFrame(ques, 1).addCallback(
self._cbVerifyHostKey, pubKey, khHost, keyType
)
def _cbVerifyHostKey(self, ans, pubKey, khHost, keyType):
if ans.lower() not in ("yes", "no"):
return deferredAskFrame("Please type 'yes' or 'no': ", 1).addCallback(
self._cbVerifyHostKey, pubKey, khHost, keyType
)
if ans.lower() == "no":
frame.write("Host key verification failed.\r\n")
raise error.ConchError("bad host key")
try:
frame.write(
"Warning: Permanently added '%s' (%s) to the list of "
"known hosts.\r\n"
% (khHost, {b"ssh-dss": "DSA", b"ssh-rsa": "RSA"}[keyType])
)
with open(os.path.expanduser("~/.ssh/known_hosts"), "a") as known_hosts:
encodedKey = base64.b64encode(pubKey)
known_hosts.write(f"\n{khHost} {keyType} {encodedKey}")
except BaseException:
log.deferr()
raise error.ConchError
def connectionSecure(self):
if options["user"]:
user = options["user"]
else:
user = getpass.getuser()
self.requestService(SSHUserAuthClient(user, SSHConnection()))
class SSHUserAuthClient(userauth.SSHUserAuthClient):
usedFiles: List[str] = []
def getPassword(self, prompt=None):
if not prompt:
prompt = "{}@{}'s password: ".format(self.user, options["host"])
return deferredAskFrame(prompt, 0)
def getPublicKey(self):
files = [x for x in options.identitys if x not in self.usedFiles]
if not files:
return None
file = files[0]
log.msg(file)
self.usedFiles.append(file)
file = os.path.expanduser(file)
file += ".pub"
if not os.path.exists(file):
return
try:
return keys.Key.fromFile(file).blob()
except BaseException:
return self.getPublicKey() # try again
def getPrivateKey(self):
file = os.path.expanduser(self.usedFiles[-1])
if not os.path.exists(file):
return None
try:
return defer.succeed(keys.Key.fromFile(file).keyObject)
except keys.BadKeyError as e:
if e.args[0] == "encrypted key with no password":
prompt = "Enter passphrase for key '%s': " % self.usedFiles[-1]
return deferredAskFrame(prompt, 0).addCallback(self._cbGetPrivateKey, 0)
def _cbGetPrivateKey(self, ans, count):
file = os.path.expanduser(self.usedFiles[-1])
try:
return keys.Key.fromFile(file, password=ans).keyObject
except keys.BadKeyError:
if count == 2:
raise
prompt = "Enter passphrase for key '%s': " % self.usedFiles[-1]
return deferredAskFrame(prompt, 0).addCallback(
self._cbGetPrivateKey, count + 1
)
class SSHConnection(connection.SSHConnection):
def serviceStarted(self):
if not options["noshell"]:
self.openChannel(SSHSession())
if options.localForwards:
for localPort, hostport in options.localForwards:
reactor.listenTCP(
localPort,
forwarding.SSHListenForwardingFactory(
self, hostport, forwarding.SSHListenClientForwardingChannel
),
)
if options.remoteForwards:
for remotePort, hostport in options.remoteForwards:
log.msg(
"asking for remote forwarding for {}:{}".format(
remotePort, hostport
)
)
data = forwarding.packGlobal_tcpip_forward(("0.0.0.0", remotePort))
self.sendGlobalRequest("tcpip-forward", data)
self.remoteForwards[remotePort] = hostport
class SSHSession(channel.SSHChannel):
name = b"session"
def channelOpen(self, foo):
# global globalSession
# globalSession = self
# turn off local echo
self.escapeMode = 1
c = session.SSHSessionClient()
if options["escape"]:
c.dataReceived = self.handleInput
else:
c.dataReceived = self.write
c.connectionLost = self.sendEOF
frame.callback = c.dataReceived
frame.canvas.focus_force()
if options["subsystem"]:
self.conn.sendRequest(self, b"subsystem", common.NS(options["command"]))
elif options["command"]:
if options["tty"]:
term = os.environ.get("TERM", "xterm")
# winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, '12345678')
winSize = (25, 80, 0, 0) # struct.unpack('4H', winsz)
ptyReqData = session.packRequest_pty_req(term, winSize, "")
self.conn.sendRequest(self, b"pty-req", ptyReqData)
self.conn.sendRequest(self, "exec", common.NS(options["command"]))
else:
if not options["notty"]:
term = os.environ.get("TERM", "xterm")
# winsz = fcntl.ioctl(fd, tty.TIOCGWINSZ, '12345678')
winSize = (25, 80, 0, 0) # struct.unpack('4H', winsz)
ptyReqData = session.packRequest_pty_req(term, winSize, "")
self.conn.sendRequest(self, b"pty-req", ptyReqData)
self.conn.sendRequest(self, b"shell", b"")
self.conn.transport.transport.setTcpNoDelay(1)
def handleInput(self, char):
# log.msg('handling %s' % repr(char))
if char in ("\n", "\r"):
self.escapeMode = 1
self.write(char)
elif self.escapeMode == 1 and char == options["escape"]:
self.escapeMode = 2
elif self.escapeMode == 2:
self.escapeMode = 1 # so we can chain escapes together
if char == ".": # disconnect
log.msg("disconnecting from escape")
reactor.stop()
return
elif char == "\x1a": # ^Z, suspend
# following line courtesy of Erwin@freenode
os.kill(os.getpid(), signal.SIGSTOP)
return
elif char == "R": # rekey connection
log.msg("rekeying connection")
self.conn.transport.sendKexInit()
return
self.write("~" + char)
else:
self.escapeMode = 0
self.write(char)
def dataReceived(self, data):
data = data.decode("utf-8")
if options["ansilog"]:
print(repr(data))
frame.write(data)
def extReceived(self, t, data):
if t == connection.EXTENDED_DATA_STDERR:
log.msg("got %s stderr data" % len(data))
sys.stderr.write(data)
sys.stderr.flush()
def eofReceived(self):
log.msg("got eof")
sys.stdin.close()
def closed(self):
log.msg("closed %s" % self)
if len(self.conn.channels) == 1: # just us left
reactor.stop()
def request_exit_status(self, data):
global exitStatus
exitStatus = int(struct.unpack(">L", data)[0])
log.msg("exit status: %s" % exitStatus)
def sendEOF(self):
self.conn.sendEOF(self)
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,10 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
#
"""
An SSHv2 implementation for Twisted. Part of the Twisted.Conch package.
Maintainer: Paul Swartz
"""

View File

@@ -0,0 +1,293 @@
# -*- test-case-name: twisted.conch.test.test_transport -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
SSH key exchange handling.
"""
from hashlib import sha1, sha256, sha384, sha512
from zope.interface import Attribute, Interface, implementer
from twisted.conch import error
class _IKexAlgorithm(Interface):
"""
An L{_IKexAlgorithm} describes a key exchange algorithm.
"""
preference = Attribute(
"An L{int} giving the preference of the algorithm when negotiating "
"key exchange. Algorithms with lower precedence values are more "
"preferred."
)
hashProcessor = Attribute(
"A callable hash algorithm constructor (e.g. C{hashlib.sha256}) "
"suitable for use with this key exchange algorithm."
)
class _IFixedGroupKexAlgorithm(_IKexAlgorithm):
"""
An L{_IFixedGroupKexAlgorithm} describes a key exchange algorithm with a
fixed prime / generator group.
"""
prime = Attribute(
"An L{int} giving the prime number used in Diffie-Hellman key "
"exchange, or L{None} if not applicable."
)
generator = Attribute(
"An L{int} giving the generator number used in Diffie-Hellman key "
"exchange, or L{None} if not applicable. (This is not related to "
"Python generator functions.)"
)
class _IEllipticCurveExchangeKexAlgorithm(_IKexAlgorithm):
"""
An L{_IEllipticCurveExchangeKexAlgorithm} describes a key exchange algorithm
that uses an elliptic curve exchange between the client and server.
"""
class _IGroupExchangeKexAlgorithm(_IKexAlgorithm):
"""
An L{_IGroupExchangeKexAlgorithm} describes a key exchange algorithm
that uses group exchange between the client and server.
A prime / generator group should be chosen at run time based on the
requested size. See RFC 4419.
"""
@implementer(_IEllipticCurveExchangeKexAlgorithm)
class _Curve25519SHA256:
"""
Elliptic Curve Key Exchange using Curve25519 and SHA256. Defined in
U{https://datatracker.ietf.org/doc/draft-ietf-curdle-ssh-curves/}.
"""
preference = 1
hashProcessor = sha256
@implementer(_IEllipticCurveExchangeKexAlgorithm)
class _Curve25519SHA256LibSSH:
"""
As L{_Curve25519SHA256}, but with a pre-standardized algorithm name.
"""
preference = 2
hashProcessor = sha256
@implementer(_IEllipticCurveExchangeKexAlgorithm)
class _ECDH256:
"""
Elliptic Curve Key Exchange with SHA-256 as HASH. Defined in
RFC 5656.
Note that C{ecdh-sha2-nistp256} takes priority over nistp384 or nistp512.
This is the same priority from OpenSSH.
C{ecdh-sha2-nistp256} is considered preety good cryptography.
If you need something better consider using C{curve25519-sha256}.
"""
preference = 3
hashProcessor = sha256
@implementer(_IEllipticCurveExchangeKexAlgorithm)
class _ECDH384:
"""
Elliptic Curve Key Exchange with SHA-384 as HASH. Defined in
RFC 5656.
"""
preference = 4
hashProcessor = sha384
@implementer(_IEllipticCurveExchangeKexAlgorithm)
class _ECDH512:
"""
Elliptic Curve Key Exchange with SHA-512 as HASH. Defined in
RFC 5656.
"""
preference = 5
hashProcessor = sha512
@implementer(_IGroupExchangeKexAlgorithm)
class _DHGroupExchangeSHA256:
"""
Diffie-Hellman Group and Key Exchange with SHA-256 as HASH. Defined in
RFC 4419, 4.2.
"""
preference = 6
hashProcessor = sha256
@implementer(_IGroupExchangeKexAlgorithm)
class _DHGroupExchangeSHA1:
"""
Diffie-Hellman Group and Key Exchange with SHA-1 as HASH. Defined in
RFC 4419, 4.1.
"""
preference = 7
hashProcessor = sha1
@implementer(_IFixedGroupKexAlgorithm)
class _DHGroup14SHA1:
"""
Diffie-Hellman key exchange with SHA-1 as HASH and Oakley Group 14
(2048-bit MODP Group). Defined in RFC 4253, 8.2.
"""
preference = 8
hashProcessor = sha1
# Diffie-Hellman primes from Oakley Group 14 (RFC 3526, 3).
prime = int(
"323170060713110073003389139264238282488179412411402391128420"
"097514007417066343542226196894173635693471179017379097041917"
"546058732091950288537589861856221532121754125149017745202702"
"357960782362488842461894775876411059286460994117232454266225"
"221932305409190376805242355191256797158701170010580558776510"
"388618472802579760549035697325615261670813393617995413364765"
"591603683178967290731783845896806396719009772021941686472258"
"710314113364293195361934716365332097170774482279885885653692"
"086452966360772502689555059283627511211740969729980684105543"
"595848665832916421362182310789909994486524682624169720359118"
"52507045361090559"
)
generator = 2
# Which ECDH hash function to use is dependent on the size.
_kexAlgorithms = {
b"curve25519-sha256": _Curve25519SHA256(),
b"curve25519-sha256@libssh.org": _Curve25519SHA256LibSSH(),
b"diffie-hellman-group-exchange-sha256": _DHGroupExchangeSHA256(),
b"diffie-hellman-group-exchange-sha1": _DHGroupExchangeSHA1(),
b"diffie-hellman-group14-sha1": _DHGroup14SHA1(),
b"ecdh-sha2-nistp256": _ECDH256(),
b"ecdh-sha2-nistp384": _ECDH384(),
b"ecdh-sha2-nistp521": _ECDH512(),
}
def getKex(kexAlgorithm):
"""
Get a description of a named key exchange algorithm.
@param kexAlgorithm: The key exchange algorithm name.
@type kexAlgorithm: L{bytes}
@return: A description of the key exchange algorithm named by
C{kexAlgorithm}.
@rtype: L{_IKexAlgorithm}
@raises ConchError: if the key exchange algorithm is not found.
"""
if kexAlgorithm not in _kexAlgorithms:
raise error.ConchError(f"Unsupported key exchange algorithm: {kexAlgorithm}")
return _kexAlgorithms[kexAlgorithm]
def isEllipticCurve(kexAlgorithm):
"""
Returns C{True} if C{kexAlgorithm} is an elliptic curve.
@param kexAlgorithm: The key exchange algorithm name.
@type kexAlgorithm: C{str}
@return: C{True} if C{kexAlgorithm} is an elliptic curve,
otherwise C{False}.
@rtype: C{bool}
"""
return _IEllipticCurveExchangeKexAlgorithm.providedBy(getKex(kexAlgorithm))
def isFixedGroup(kexAlgorithm):
"""
Returns C{True} if C{kexAlgorithm} has a fixed prime / generator group.
@param kexAlgorithm: The key exchange algorithm name.
@type kexAlgorithm: L{bytes}
@return: C{True} if C{kexAlgorithm} has a fixed prime / generator group,
otherwise C{False}.
@rtype: L{bool}
"""
return _IFixedGroupKexAlgorithm.providedBy(getKex(kexAlgorithm))
def getHashProcessor(kexAlgorithm):
"""
Get the hash algorithm callable to use in key exchange.
@param kexAlgorithm: The key exchange algorithm name.
@type kexAlgorithm: L{bytes}
@return: A callable hash algorithm constructor (e.g. C{hashlib.sha256}).
@rtype: C{callable}
"""
kex = getKex(kexAlgorithm)
return kex.hashProcessor
def getDHGeneratorAndPrime(kexAlgorithm):
"""
Get the generator and the prime to use in key exchange.
@param kexAlgorithm: The key exchange algorithm name.
@type kexAlgorithm: L{bytes}
@return: A L{tuple} containing L{int} generator and L{int} prime.
@rtype: L{tuple}
"""
kex = getKex(kexAlgorithm)
return kex.generator, kex.prime
def getSupportedKeyExchanges():
"""
Get a list of supported key exchange algorithm names in order of
preference.
@return: A C{list} of supported key exchange algorithm names.
@rtype: C{list} of L{bytes}
"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import ec
from twisted.conch.ssh.keys import _curveTable
backend = default_backend()
kexAlgorithms = _kexAlgorithms.copy()
for keyAlgorithm in list(kexAlgorithms):
if keyAlgorithm.startswith(b"ecdh"):
keyAlgorithmDsa = keyAlgorithm.replace(b"ecdh", b"ecdsa")
supported = backend.elliptic_curve_exchange_algorithm_supported(
ec.ECDH(), _curveTable[keyAlgorithmDsa]
)
elif keyAlgorithm.startswith(b"curve25519-sha256"):
supported = backend.x25519_supported()
else:
supported = True
if not supported:
kexAlgorithms.pop(keyAlgorithm)
return sorted(
kexAlgorithms, key=lambda kexAlgorithm: kexAlgorithms[kexAlgorithm].preference
)

View File

@@ -0,0 +1,43 @@
# -*- test-case-name: twisted.conch.test.test_address -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Address object for SSH network connections.
Maintainer: Paul Swartz
@since: 12.1
"""
from zope.interface import implementer
from twisted.internet.interfaces import IAddress
from twisted.python import util
@implementer(IAddress)
class SSHTransportAddress(util.FancyEqMixin):
"""
Object representing an SSH Transport endpoint.
This is used to ensure that any code inspecting this address and
attempting to construct a similar connection based upon it is not
mislead into creating a transport which is not similar to the one it is
indicating.
@ivar address: An instance of an object which implements I{IAddress} to
which this transport address is connected.
"""
compareAttributes = ("address",)
def __init__(self, address):
self.address = address
def __repr__(self) -> str:
return f"SSHTransportAddress({self.address!r})"
def __hash__(self):
return hash(("SSH", self.address))

View File

@@ -0,0 +1,278 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Implements the SSH v2 key agent protocol. This protocol is documented in the
SSH source code, in the file
U{PROTOCOL.agent<http://www.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.agent>}.
Maintainer: Paul Swartz
"""
import struct
from twisted.conch.error import ConchError, MissingKeyStoreError
from twisted.conch.ssh import keys
from twisted.conch.ssh.common import NS, getMP, getNS
from twisted.internet import defer, protocol
class SSHAgentClient(protocol.Protocol):
"""
The client side of the SSH agent protocol. This is equivalent to
ssh-add(1) and can be used with either ssh-agent(1) or the SSHAgentServer
protocol, also in this package.
"""
def __init__(self):
self.buf = b""
self.deferreds = []
def dataReceived(self, data):
self.buf += data
while 1:
if len(self.buf) <= 4:
return
packLen = struct.unpack("!L", self.buf[:4])[0]
if len(self.buf) < 4 + packLen:
return
packet, self.buf = self.buf[4 : 4 + packLen], self.buf[4 + packLen :]
reqType = ord(packet[0:1])
d = self.deferreds.pop(0)
if reqType == AGENT_FAILURE:
d.errback(ConchError("agent failure"))
elif reqType == AGENT_SUCCESS:
d.callback(b"")
else:
d.callback(packet)
def sendRequest(self, reqType, data):
pack = struct.pack("!LB", len(data) + 1, reqType) + data
self.transport.write(pack)
d = defer.Deferred()
self.deferreds.append(d)
return d
def requestIdentities(self):
"""
@return: A L{Deferred} which will fire with a list of all keys found in
the SSH agent. The list of keys is comprised of (public key blob,
comment) tuples.
"""
d = self.sendRequest(AGENTC_REQUEST_IDENTITIES, b"")
d.addCallback(self._cbRequestIdentities)
return d
def _cbRequestIdentities(self, data):
"""
Unpack a collection of identities into a list of tuples comprised of
public key blobs and comments.
"""
if ord(data[0:1]) != AGENT_IDENTITIES_ANSWER:
raise ConchError("unexpected response: %i" % ord(data[0:1]))
numKeys = struct.unpack("!L", data[1:5])[0]
result = []
data = data[5:]
for i in range(numKeys):
blob, data = getNS(data)
comment, data = getNS(data)
result.append((blob, comment))
return result
def addIdentity(self, blob, comment=b""):
"""
Add a private key blob to the agent's collection of keys.
"""
req = blob
req += NS(comment)
return self.sendRequest(AGENTC_ADD_IDENTITY, req)
def signData(self, blob, data):
"""
Request that the agent sign the given C{data} with the private key
which corresponds to the public key given by C{blob}. The private
key should have been added to the agent already.
@type blob: L{bytes}
@type data: L{bytes}
@return: A L{Deferred} which fires with a signature for given data
created with the given key.
"""
req = NS(blob)
req += NS(data)
req += b"\000\000\000\000" # flags
return self.sendRequest(AGENTC_SIGN_REQUEST, req).addCallback(self._cbSignData)
def _cbSignData(self, data):
if ord(data[0:1]) != AGENT_SIGN_RESPONSE:
raise ConchError("unexpected data: %i" % ord(data[0:1]))
signature = getNS(data[1:])[0]
return signature
def removeIdentity(self, blob):
"""
Remove the private key corresponding to the public key in blob from the
running agent.
"""
req = NS(blob)
return self.sendRequest(AGENTC_REMOVE_IDENTITY, req)
def removeAllIdentities(self):
"""
Remove all keys from the running agent.
"""
return self.sendRequest(AGENTC_REMOVE_ALL_IDENTITIES, b"")
class SSHAgentServer(protocol.Protocol):
"""
The server side of the SSH agent protocol. This is equivalent to
ssh-agent(1) and can be used with either ssh-add(1) or the SSHAgentClient
protocol, also in this package.
"""
def __init__(self):
self.buf = b""
def dataReceived(self, data):
self.buf += data
while 1:
if len(self.buf) <= 4:
return
packLen = struct.unpack("!L", self.buf[:4])[0]
if len(self.buf) < 4 + packLen:
return
packet, self.buf = self.buf[4 : 4 + packLen], self.buf[4 + packLen :]
reqType = ord(packet[0:1])
reqName = messages.get(reqType, None)
if not reqName:
self.sendResponse(AGENT_FAILURE, b"")
else:
f = getattr(self, "agentc_%s" % reqName)
if getattr(self.factory, "keys", None) is None:
self.sendResponse(AGENT_FAILURE, b"")
raise MissingKeyStoreError()
f(packet[1:])
def sendResponse(self, reqType, data):
pack = struct.pack("!LB", len(data) + 1, reqType) + data
self.transport.write(pack)
def agentc_REQUEST_IDENTITIES(self, data):
"""
Return all of the identities that have been added to the server
"""
assert data == b""
numKeys = len(self.factory.keys)
resp = []
resp.append(struct.pack("!L", numKeys))
for key, comment in self.factory.keys.values():
resp.append(NS(key.blob())) # yes, wrapped in an NS
resp.append(NS(comment))
self.sendResponse(AGENT_IDENTITIES_ANSWER, b"".join(resp))
def agentc_SIGN_REQUEST(self, data):
"""
Data is a structure with a reference to an already added key object and
some data that the clients wants signed with that key. If the key
object wasn't loaded, return AGENT_FAILURE, else return the signature.
"""
blob, data = getNS(data)
if blob not in self.factory.keys:
return self.sendResponse(AGENT_FAILURE, b"")
signData, data = getNS(data)
assert data == b"\000\000\000\000"
self.sendResponse(
AGENT_SIGN_RESPONSE, NS(self.factory.keys[blob][0].sign(signData))
)
def agentc_ADD_IDENTITY(self, data):
"""
Adds a private key to the agent's collection of identities. On
subsequent interactions, the private key can be accessed using only the
corresponding public key.
"""
# need to pre-read the key data so we can get past it to the comment string
keyType, rest = getNS(data)
if keyType == b"ssh-rsa":
nmp = 6
elif keyType == b"ssh-dss":
nmp = 5
else:
raise keys.BadKeyError("unknown blob type: %s" % keyType)
rest = getMP(rest, nmp)[
-1
] # ignore the key data for now, we just want the comment
comment, rest = getNS(rest) # the comment, tacked onto the end of the key blob
k = keys.Key.fromString(data, type="private_blob") # not wrapped in NS here
self.factory.keys[k.blob()] = (k, comment)
self.sendResponse(AGENT_SUCCESS, b"")
def agentc_REMOVE_IDENTITY(self, data):
"""
Remove a specific key from the agent's collection of identities.
"""
blob, _ = getNS(data)
k = keys.Key.fromString(blob, type="blob")
del self.factory.keys[k.blob()]
self.sendResponse(AGENT_SUCCESS, b"")
def agentc_REMOVE_ALL_IDENTITIES(self, data):
"""
Remove all keys from the agent's collection of identities.
"""
assert data == b""
self.factory.keys = {}
self.sendResponse(AGENT_SUCCESS, b"")
# v1 messages that we ignore because we don't keep v1 keys
# open-ssh sends both v1 and v2 commands, so we have to
# do no-ops for v1 commands or we'll get "bad request" errors
def agentc_REQUEST_RSA_IDENTITIES(self, data):
"""
v1 message for listing RSA1 keys; superseded by
agentc_REQUEST_IDENTITIES, which handles different key types.
"""
self.sendResponse(AGENT_RSA_IDENTITIES_ANSWER, struct.pack("!L", 0))
def agentc_REMOVE_RSA_IDENTITY(self, data):
"""
v1 message for removing RSA1 keys; superseded by
agentc_REMOVE_IDENTITY, which handles different key types.
"""
self.sendResponse(AGENT_SUCCESS, b"")
def agentc_REMOVE_ALL_RSA_IDENTITIES(self, data):
"""
v1 message for removing all RSA1 keys; superseded by
agentc_REMOVE_ALL_IDENTITIES, which handles different key types.
"""
self.sendResponse(AGENT_SUCCESS, b"")
AGENTC_REQUEST_RSA_IDENTITIES = 1
AGENT_RSA_IDENTITIES_ANSWER = 2
AGENT_FAILURE = 5
AGENT_SUCCESS = 6
AGENTC_REMOVE_RSA_IDENTITY = 8
AGENTC_REMOVE_ALL_RSA_IDENTITIES = 9
AGENTC_REQUEST_IDENTITIES = 11
AGENT_IDENTITIES_ANSWER = 12
AGENTC_SIGN_REQUEST = 13
AGENT_SIGN_RESPONSE = 14
AGENTC_ADD_IDENTITY = 17
AGENTC_REMOVE_IDENTITY = 18
AGENTC_REMOVE_ALL_IDENTITIES = 19
messages = {}
for name, value in locals().copy().items():
if name[:7] == "AGENTC_":
messages[value] = name[7:] # doesn't handle doubles

View File

@@ -0,0 +1,312 @@
# -*- test-case-name: twisted.conch.test.test_channel -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
The parent class for all the SSH Channels. Currently implemented channels
are session, direct-tcp, and forwarded-tcp.
Maintainer: Paul Swartz
"""
from zope.interface import implementer
from twisted.internet import interfaces
from twisted.logger import Logger
from twisted.python import log
@implementer(interfaces.ITransport)
class SSHChannel(log.Logger):
"""
A class that represents a multiplexed channel over an SSH connection.
The channel has a local window which is the maximum amount of data it will
receive, and a remote which is the maximum amount of data the remote side
will accept. There is also a maximum packet size for any individual data
packet going each way.
@ivar name: the name of the channel.
@type name: L{bytes}
@ivar localWindowSize: the maximum size of the local window in bytes.
@type localWindowSize: L{int}
@ivar localWindowLeft: how many bytes are left in the local window.
@type localWindowLeft: L{int}
@ivar localMaxPacket: the maximum size of packet we will accept in bytes.
@type localMaxPacket: L{int}
@ivar remoteWindowLeft: how many bytes are left in the remote window.
@type remoteWindowLeft: L{int}
@ivar remoteMaxPacket: the maximum size of a packet the remote side will
accept in bytes.
@type remoteMaxPacket: L{int}
@ivar conn: the connection this channel is multiplexed through.
@type conn: L{SSHConnection}
@ivar data: any data to send to the other side when the channel is
requested.
@type data: L{bytes}
@ivar avatar: an avatar for the logged-in user (if a server channel)
@ivar localClosed: True if we aren't accepting more data.
@type localClosed: L{bool}
@ivar remoteClosed: True if the other side isn't accepting more data.
@type remoteClosed: L{bool}
"""
_log = Logger()
name: bytes = None # type: ignore[assignment] # only needed for client channels
def __init__(
self,
localWindow=0,
localMaxPacket=0,
remoteWindow=0,
remoteMaxPacket=0,
conn=None,
data=None,
avatar=None,
):
self.localWindowSize = localWindow or 131072
self.localWindowLeft = self.localWindowSize
self.localMaxPacket = localMaxPacket or 32768
self.remoteWindowLeft = remoteWindow
self.remoteMaxPacket = remoteMaxPacket
self.areWriting = 1
self.conn = conn
self.data = data
self.avatar = avatar
self.specificData = b""
self.buf = b""
self.extBuf = []
self.closing = 0
self.localClosed = 0
self.remoteClosed = 0
self.id = None # gets set later by SSHConnection
def __str__(self) -> str:
return self.__bytes__().decode("ascii")
def __bytes__(self) -> bytes:
"""
Return a byte string representation of the channel
"""
name = self.name
if not name:
name = b"None"
return b"<SSHChannel %b (lw %d rw %d)>" % (
name,
self.localWindowLeft,
self.remoteWindowLeft,
)
def logPrefix(self):
id = (self.id is not None and str(self.id)) or "unknown"
if self.name:
name = self.name.decode("ascii")
else:
name = "None"
return f"SSHChannel {name} ({id}) on {self.conn.logPrefix()}"
def channelOpen(self, specificData):
"""
Called when the channel is opened. specificData is any data that the
other side sent us when opening the channel.
@type specificData: L{bytes}
"""
self._log.info("channel open")
def openFailed(self, reason):
"""
Called when the open failed for some reason.
reason.desc is a string descrption, reason.code the SSH error code.
@type reason: L{error.ConchError}
"""
self._log.error("other side refused open\nreason: {reason}", reason=reason)
def addWindowBytes(self, data):
"""
Called when bytes are added to the remote window. By default it clears
the data buffers.
@type data: L{bytes}
"""
self.remoteWindowLeft = self.remoteWindowLeft + data
if not self.areWriting and not self.closing:
self.areWriting = True
self.startWriting()
if self.buf:
b = self.buf
self.buf = b""
self.write(b)
if self.extBuf:
b = self.extBuf
self.extBuf = []
for type, data in b:
self.writeExtended(type, data)
def requestReceived(self, requestType, data):
"""
Called when a request is sent to this channel. By default it delegates
to self.request_<requestType>.
If this function returns true, the request succeeded, otherwise it
failed.
@type requestType: L{bytes}
@type data: L{bytes}
@rtype: L{bool}
"""
foo = requestType.replace(b"-", b"_").decode("ascii")
f = getattr(self, "request_" + foo, None)
if f:
return f(data)
self._log.info("unhandled request for {requestType}", requestType=requestType)
return 0
def dataReceived(self, data):
"""
Called when we receive data.
@type data: L{bytes}
"""
self._log.debug("got data {data}", data=data)
def extReceived(self, dataType, data):
"""
Called when we receive extended data (usually standard error).
@type dataType: L{int}
@type data: L{str}
"""
self._log.debug(
"got extended data {dataType} {data!r}", dataType=dataType, data=data
)
def eofReceived(self):
"""
Called when the other side will send no more data.
"""
self._log.info("remote eof")
def closeReceived(self):
"""
Called when the other side has closed the channel.
"""
self._log.info("remote close")
self.loseConnection()
def closed(self):
"""
Called when the channel is closed. This means that both our side and
the remote side have closed the channel.
"""
self._log.info("closed")
def write(self, data):
"""
Write some data to the channel. If there is not enough remote window
available, buffer until it is. Otherwise, split the data into
packets of length remoteMaxPacket and send them.
@type data: L{bytes}
"""
if self.buf:
self.buf += data
return
top = len(data)
if top > self.remoteWindowLeft:
data, self.buf = (
data[: self.remoteWindowLeft],
data[self.remoteWindowLeft :],
)
self.areWriting = 0
self.stopWriting()
top = self.remoteWindowLeft
rmp = self.remoteMaxPacket
write = self.conn.sendData
r = range(0, top, rmp)
for offset in r:
write(self, data[offset : offset + rmp])
self.remoteWindowLeft -= top
if self.closing and not self.buf:
self.loseConnection() # try again
def writeExtended(self, dataType, data):
"""
Send extended data to this channel. If there is not enough remote
window available, buffer until there is. Otherwise, split the data
into packets of length remoteMaxPacket and send them.
@type dataType: L{int}
@type data: L{bytes}
"""
if self.extBuf:
if self.extBuf[-1][0] == dataType:
self.extBuf[-1][1] += data
else:
self.extBuf.append([dataType, data])
return
if len(data) > self.remoteWindowLeft:
data, self.extBuf = (
data[: self.remoteWindowLeft],
[[dataType, data[self.remoteWindowLeft :]]],
)
self.areWriting = 0
self.stopWriting()
while len(data) > self.remoteMaxPacket:
self.conn.sendExtendedData(self, dataType, data[: self.remoteMaxPacket])
data = data[self.remoteMaxPacket :]
self.remoteWindowLeft -= self.remoteMaxPacket
if data:
self.conn.sendExtendedData(self, dataType, data)
self.remoteWindowLeft -= len(data)
if self.closing:
self.loseConnection() # try again
def writeSequence(self, data):
"""
Part of the Transport interface. Write a list of strings to the
channel.
@type data: C{list} of L{str}
"""
self.write(b"".join(data))
def loseConnection(self):
"""
Close the channel if there is no buferred data. Otherwise, note the
request and return.
"""
self.closing = 1
if not self.buf and not self.extBuf:
self.conn.sendClose(self)
def getPeer(self):
"""
See: L{ITransport.getPeer}
@return: The remote address of this connection.
@rtype: L{SSHTransportAddress}.
"""
return self.conn.transport.getPeer()
def getHost(self):
"""
See: L{ITransport.getHost}
@return: An address describing this side of the connection.
@rtype: L{SSHTransportAddress}.
"""
return self.conn.transport.getHost()
def stopWriting(self):
"""
Called when the remote buffer is full, as a hint to stop writing.
This can be ignored, but it can be helpful.
"""
def startWriting(self):
"""
Called when the remote buffer has more room, as a hint to continue
writing.
"""

View File

@@ -0,0 +1,85 @@
# -*- test-case-name: twisted.conch.test.test_ssh -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
Common functions for the SSH classes.
Maintainer: Paul Swartz
"""
import struct
from cryptography.utils import int_to_bytes
from twisted.python.deprecate import deprecated
from twisted.python.versions import Version
__all__ = ["NS", "getNS", "MP", "getMP", "ffs"]
def NS(t):
"""
net string
"""
if isinstance(t, str):
t = t.encode("utf-8")
return struct.pack("!L", len(t)) + t
def getNS(s, count=1):
"""
get net string
"""
ns = []
c = 0
for i in range(count):
(l,) = struct.unpack("!L", s[c : c + 4])
ns.append(s[c + 4 : 4 + l + c])
c += 4 + l
return tuple(ns) + (s[c:],)
def MP(number):
if number == 0:
return b"\000" * 4
assert number > 0
bn = int_to_bytes(number)
if ord(bn[0:1]) & 128:
bn = b"\000" + bn
return struct.pack(">L", len(bn)) + bn
def getMP(data, count=1):
"""
Get multiple precision integer out of the string. A multiple precision
integer is stored as a 4-byte length followed by length bytes of the
integer. If count is specified, get count integers out of the string.
The return value is a tuple of count integers followed by the rest of
the data.
"""
mp = []
c = 0
for i in range(count):
(length,) = struct.unpack(">L", data[c : c + 4])
mp.append(int.from_bytes(data[c + 4 : c + 4 + length], "big"))
c += 4 + length
return tuple(mp) + (data[c:],)
def ffs(c, s):
"""
first from second
goes through the first list, looking for items in the second, returns the first one
"""
for i in c:
if i in s:
return i
@deprecated(Version("Twisted", 16, 5, 0))
def install():
# This used to install gmpy, but is technically public API, so just do
# nothing.
pass

View File

@@ -0,0 +1,679 @@
# -*- test-case-name: twisted.conch.test.test_connection -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
This module contains the implementation of the ssh-connection service, which
allows access to the shell and port-forwarding.
Maintainer: Paul Swartz
"""
import string
import struct
import twisted.internet.error
from twisted.conch import error
from twisted.conch.ssh import common, service
from twisted.internet import defer
from twisted.logger import Logger
from twisted.python.compat import nativeString, networkString
class SSHConnection(service.SSHService):
"""
An implementation of the 'ssh-connection' service. It is used to
multiplex multiple channels over the single SSH connection.
@ivar localChannelID: the next number to use as a local channel ID.
@type localChannelID: L{int}
@ivar channels: a L{dict} mapping a local channel ID to C{SSHChannel}
subclasses.
@type channels: L{dict}
@ivar localToRemoteChannel: a L{dict} mapping a local channel ID to a
remote channel ID.
@type localToRemoteChannel: L{dict}
@ivar channelsToRemoteChannel: a L{dict} mapping a C{SSHChannel} subclass
to remote channel ID.
@type channelsToRemoteChannel: L{dict}
@ivar deferreds: a L{dict} mapping a local channel ID to a C{list} of
C{Deferreds} for outstanding channel requests. Also, the 'global'
key stores the C{list} of pending global request C{Deferred}s.
"""
name = b"ssh-connection"
_log = Logger()
def __init__(self):
self.localChannelID = 0 # this is the current # to use for channel ID
# local channel ID -> remote channel ID
self.localToRemoteChannel = {}
# local channel ID -> subclass of SSHChannel
self.channels = {}
# subclass of SSHChannel -> remote channel ID
self.channelsToRemoteChannel = {}
# local channel -> list of deferreds for pending requests
# or 'global' -> list of deferreds for global requests
self.deferreds = {"global": []}
self.transport = None # gets set later
def serviceStarted(self):
if hasattr(self.transport, "avatar"):
self.transport.avatar.conn = self
def serviceStopped(self):
"""
Called when the connection is stopped.
"""
# Close any fully open channels
for channel in list(self.channelsToRemoteChannel.keys()):
self.channelClosed(channel)
# Indicate failure to any channels that were in the process of
# opening but not yet open.
while self.channels:
(_, channel) = self.channels.popitem()
channel.openFailed(twisted.internet.error.ConnectionLost())
# Errback any unfinished global requests.
self._cleanupGlobalDeferreds()
def _cleanupGlobalDeferreds(self):
"""
All pending requests that have returned a deferred must be errbacked
when this service is stopped, otherwise they might be left uncalled and
uncallable.
"""
for d in self.deferreds["global"]:
d.errback(error.ConchError("Connection stopped."))
del self.deferreds["global"][:]
# packet methods
def ssh_GLOBAL_REQUEST(self, packet):
"""
The other side has made a global request. Payload::
string request type
bool want reply
<request specific data>
This dispatches to self.gotGlobalRequest.
"""
requestType, rest = common.getNS(packet)
wantReply, rest = ord(rest[0:1]), rest[1:]
ret = self.gotGlobalRequest(requestType, rest)
if wantReply:
reply = MSG_REQUEST_FAILURE
data = b""
if ret:
reply = MSG_REQUEST_SUCCESS
if isinstance(ret, (tuple, list)):
data = ret[1]
self.transport.sendPacket(reply, data)
def ssh_REQUEST_SUCCESS(self, packet):
"""
Our global request succeeded. Get the appropriate Deferred and call
it back with the packet we received.
"""
self._log.debug("global request success")
self.deferreds["global"].pop(0).callback(packet)
def ssh_REQUEST_FAILURE(self, packet):
"""
Our global request failed. Get the appropriate Deferred and errback
it with the packet we received.
"""
self._log.debug("global request failure")
self.deferreds["global"].pop(0).errback(
error.ConchError("global request failed", packet)
)
def ssh_CHANNEL_OPEN(self, packet):
"""
The other side wants to get a channel. Payload::
string channel name
uint32 remote channel number
uint32 remote window size
uint32 remote maximum packet size
<channel specific data>
We get a channel from self.getChannel(), give it a local channel number
and notify the other side. Then notify the channel by calling its
channelOpen method.
"""
channelType, rest = common.getNS(packet)
senderChannel, windowSize, maxPacket = struct.unpack(">3L", rest[:12])
packet = rest[12:]
try:
channel = self.getChannel(channelType, windowSize, maxPacket, packet)
localChannel = self.localChannelID
self.localChannelID += 1
channel.id = localChannel
self.channels[localChannel] = channel
self.channelsToRemoteChannel[channel] = senderChannel
self.localToRemoteChannel[localChannel] = senderChannel
openConfirmPacket = (
struct.pack(
">4L",
senderChannel,
localChannel,
channel.localWindowSize,
channel.localMaxPacket,
)
+ channel.specificData
)
self.transport.sendPacket(MSG_CHANNEL_OPEN_CONFIRMATION, openConfirmPacket)
channel.channelOpen(packet)
except Exception as e:
self._log.failure("channel open failed")
if isinstance(e, error.ConchError):
textualInfo, reason = e.args
if isinstance(textualInfo, int):
# See #3657 and #3071
textualInfo, reason = reason, textualInfo
else:
reason = OPEN_CONNECT_FAILED
textualInfo = "unknown failure"
self.transport.sendPacket(
MSG_CHANNEL_OPEN_FAILURE,
struct.pack(">2L", senderChannel, reason)
+ common.NS(networkString(textualInfo))
+ common.NS(b""),
)
def ssh_CHANNEL_OPEN_CONFIRMATION(self, packet):
"""
The other side accepted our MSG_CHANNEL_OPEN request. Payload::
uint32 local channel number
uint32 remote channel number
uint32 remote window size
uint32 remote maximum packet size
<channel specific data>
Find the channel using the local channel number and notify its
channelOpen method.
"""
(localChannel, remoteChannel, windowSize, maxPacket) = struct.unpack(
">4L", packet[:16]
)
specificData = packet[16:]
channel = self.channels[localChannel]
channel.conn = self
self.localToRemoteChannel[localChannel] = remoteChannel
self.channelsToRemoteChannel[channel] = remoteChannel
channel.remoteWindowLeft = windowSize
channel.remoteMaxPacket = maxPacket
channel.channelOpen(specificData)
def ssh_CHANNEL_OPEN_FAILURE(self, packet):
"""
The other side did not accept our MSG_CHANNEL_OPEN request. Payload::
uint32 local channel number
uint32 reason code
string reason description
Find the channel using the local channel number and notify it by
calling its openFailed() method.
"""
localChannel, reasonCode = struct.unpack(">2L", packet[:8])
reasonDesc = common.getNS(packet[8:])[0]
channel = self.channels[localChannel]
del self.channels[localChannel]
channel.conn = self
reason = error.ConchError(reasonDesc, reasonCode)
channel.openFailed(reason)
def ssh_CHANNEL_WINDOW_ADJUST(self, packet):
"""
The other side is adding bytes to its window. Payload::
uint32 local channel number
uint32 bytes to add
Call the channel's addWindowBytes() method to add new bytes to the
remote window.
"""
localChannel, bytesToAdd = struct.unpack(">2L", packet[:8])
channel = self.channels[localChannel]
channel.addWindowBytes(bytesToAdd)
def ssh_CHANNEL_DATA(self, packet):
"""
The other side is sending us data. Payload::
uint32 local channel number
string data
Check to make sure the other side hasn't sent too much data (more
than what's in the window, or more than the maximum packet size). If
they have, close the channel. Otherwise, decrease the available
window and pass the data to the channel's dataReceived().
"""
localChannel, dataLength = struct.unpack(">2L", packet[:8])
channel = self.channels[localChannel]
# XXX should this move to dataReceived to put client in charge?
if (
dataLength > channel.localWindowLeft or dataLength > channel.localMaxPacket
): # more data than we want
self._log.error("too much data")
self.sendClose(channel)
return
# packet = packet[:channel.localWindowLeft+4]
data = common.getNS(packet[4:])[0]
channel.localWindowLeft -= dataLength
if channel.localWindowLeft < channel.localWindowSize // 2:
self.adjustWindow(
channel, channel.localWindowSize - channel.localWindowLeft
)
channel.dataReceived(data)
def ssh_CHANNEL_EXTENDED_DATA(self, packet):
"""
The other side is sending us exteneded data. Payload::
uint32 local channel number
uint32 type code
string data
Check to make sure the other side hasn't sent too much data (more
than what's in the window, or than the maximum packet size). If
they have, close the channel. Otherwise, decrease the available
window and pass the data and type code to the channel's
extReceived().
"""
localChannel, typeCode, dataLength = struct.unpack(">3L", packet[:12])
channel = self.channels[localChannel]
if dataLength > channel.localWindowLeft or dataLength > channel.localMaxPacket:
self._log.error("too much extdata")
self.sendClose(channel)
return
data = common.getNS(packet[8:])[0]
channel.localWindowLeft -= dataLength
if channel.localWindowLeft < channel.localWindowSize // 2:
self.adjustWindow(
channel, channel.localWindowSize - channel.localWindowLeft
)
channel.extReceived(typeCode, data)
def ssh_CHANNEL_EOF(self, packet):
"""
The other side is not sending any more data. Payload::
uint32 local channel number
Notify the channel by calling its eofReceived() method.
"""
localChannel = struct.unpack(">L", packet[:4])[0]
channel = self.channels[localChannel]
channel.eofReceived()
def ssh_CHANNEL_CLOSE(self, packet):
"""
The other side is closing its end; it does not want to receive any
more data. Payload::
uint32 local channel number
Notify the channnel by calling its closeReceived() method. If
the channel has also sent a close message, call self.channelClosed().
"""
localChannel = struct.unpack(">L", packet[:4])[0]
channel = self.channels[localChannel]
channel.closeReceived()
channel.remoteClosed = True
if channel.localClosed and channel.remoteClosed:
self.channelClosed(channel)
def ssh_CHANNEL_REQUEST(self, packet):
"""
The other side is sending a request to a channel. Payload::
uint32 local channel number
string request name
bool want reply
<request specific data>
Pass the message to the channel's requestReceived method. If the
other side wants a reply, add callbacks which will send the
reply.
"""
localChannel = struct.unpack(">L", packet[:4])[0]
requestType, rest = common.getNS(packet[4:])
wantReply = ord(rest[0:1])
channel = self.channels[localChannel]
d = defer.maybeDeferred(channel.requestReceived, requestType, rest[1:])
if wantReply:
d.addCallback(self._cbChannelRequest, localChannel)
d.addErrback(self._ebChannelRequest, localChannel)
return d
def _cbChannelRequest(self, result, localChannel):
"""
Called back if the other side wanted a reply to a channel request. If
the result is true, send a MSG_CHANNEL_SUCCESS. Otherwise, raise
a C{error.ConchError}
@param result: the value returned from the channel's requestReceived()
method. If it's False, the request failed.
@type result: L{bool}
@param localChannel: the local channel ID of the channel to which the
request was made.
@type localChannel: L{int}
@raises ConchError: if the result is False.
"""
if not result:
raise error.ConchError("failed request")
self.transport.sendPacket(
MSG_CHANNEL_SUCCESS,
struct.pack(">L", self.localToRemoteChannel[localChannel]),
)
def _ebChannelRequest(self, result, localChannel):
"""
Called if the other wisde wanted a reply to the channel requeset and
the channel request failed.
@param result: a Failure, but it's not used.
@param localChannel: the local channel ID of the channel to which the
request was made.
@type localChannel: L{int}
"""
self.transport.sendPacket(
MSG_CHANNEL_FAILURE,
struct.pack(">L", self.localToRemoteChannel[localChannel]),
)
def ssh_CHANNEL_SUCCESS(self, packet):
"""
Our channel request to the other side succeeded. Payload::
uint32 local channel number
Get the C{Deferred} out of self.deferreds and call it back.
"""
localChannel = struct.unpack(">L", packet[:4])[0]
if self.deferreds.get(localChannel):
d = self.deferreds[localChannel].pop(0)
d.callback("")
def ssh_CHANNEL_FAILURE(self, packet):
"""
Our channel request to the other side failed. Payload::
uint32 local channel number
Get the C{Deferred} out of self.deferreds and errback it with a
C{error.ConchError}.
"""
localChannel = struct.unpack(">L", packet[:4])[0]
if self.deferreds.get(localChannel):
d = self.deferreds[localChannel].pop(0)
d.errback(error.ConchError("channel request failed"))
# methods for users of the connection to call
def sendGlobalRequest(self, request, data, wantReply=0):
"""
Send a global request for this connection. Current this is only used
for remote->local TCP forwarding.
@type request: L{bytes}
@type data: L{bytes}
@type wantReply: L{bool}
@rtype: C{Deferred}/L{None}
"""
self.transport.sendPacket(
MSG_GLOBAL_REQUEST,
common.NS(request) + (wantReply and b"\xff" or b"\x00") + data,
)
if wantReply:
d = defer.Deferred()
self.deferreds["global"].append(d)
return d
def openChannel(self, channel, extra=b""):
"""
Open a new channel on this connection.
@type channel: subclass of C{SSHChannel}
@type extra: L{bytes}
"""
self._log.info(
"opening channel {id} with {localWindowSize} {localMaxPacket}",
id=self.localChannelID,
localWindowSize=channel.localWindowSize,
localMaxPacket=channel.localMaxPacket,
)
self.transport.sendPacket(
MSG_CHANNEL_OPEN,
common.NS(channel.name)
+ struct.pack(
">3L",
self.localChannelID,
channel.localWindowSize,
channel.localMaxPacket,
)
+ extra,
)
channel.id = self.localChannelID
self.channels[self.localChannelID] = channel
self.localChannelID += 1
def sendRequest(self, channel, requestType, data, wantReply=0):
"""
Send a request to a channel.
@type channel: subclass of C{SSHChannel}
@type requestType: L{bytes}
@type data: L{bytes}
@type wantReply: L{bool}
@rtype: C{Deferred}/L{None}
"""
if channel.localClosed:
return
self._log.debug("sending request {requestType}", requestType=requestType)
self.transport.sendPacket(
MSG_CHANNEL_REQUEST,
struct.pack(">L", self.channelsToRemoteChannel[channel])
+ common.NS(requestType)
+ (b"\1" if wantReply else b"\0")
+ data,
)
if wantReply:
d = defer.Deferred()
self.deferreds.setdefault(channel.id, []).append(d)
return d
def adjustWindow(self, channel, bytesToAdd):
"""
Tell the other side that we will receive more data. This should not
normally need to be called as it is managed automatically.
@type channel: subclass of L{SSHChannel}
@type bytesToAdd: L{int}
"""
if channel.localClosed:
return # we're already closed
packet = struct.pack(">2L", self.channelsToRemoteChannel[channel], bytesToAdd)
self.transport.sendPacket(MSG_CHANNEL_WINDOW_ADJUST, packet)
self._log.debug(
"adding {bytesToAdd} to {localWindowLeft} in channel {id}",
bytesToAdd=bytesToAdd,
localWindowLeft=channel.localWindowLeft,
id=channel.id,
)
channel.localWindowLeft += bytesToAdd
def sendData(self, channel, data):
"""
Send data to a channel. This should not normally be used: instead use
channel.write(data) as it manages the window automatically.
@type channel: subclass of L{SSHChannel}
@type data: L{bytes}
"""
if channel.localClosed:
return # we're already closed
self.transport.sendPacket(
MSG_CHANNEL_DATA,
struct.pack(">L", self.channelsToRemoteChannel[channel]) + common.NS(data),
)
def sendExtendedData(self, channel, dataType, data):
"""
Send extended data to a channel. This should not normally be used:
instead use channel.writeExtendedData(data, dataType) as it manages
the window automatically.
@type channel: subclass of L{SSHChannel}
@type dataType: L{int}
@type data: L{bytes}
"""
if channel.localClosed:
return # we're already closed
self.transport.sendPacket(
MSG_CHANNEL_EXTENDED_DATA,
struct.pack(">2L", self.channelsToRemoteChannel[channel], dataType)
+ common.NS(data),
)
def sendEOF(self, channel):
"""
Send an EOF (End of File) for a channel.
@type channel: subclass of L{SSHChannel}
"""
if channel.localClosed:
return # we're already closed
self._log.debug("sending eof")
self.transport.sendPacket(
MSG_CHANNEL_EOF, struct.pack(">L", self.channelsToRemoteChannel[channel])
)
def sendClose(self, channel):
"""
Close a channel.
@type channel: subclass of L{SSHChannel}
"""
if channel.localClosed:
return # we're already closed
self._log.info("sending close {id}", id=channel.id)
self.transport.sendPacket(
MSG_CHANNEL_CLOSE, struct.pack(">L", self.channelsToRemoteChannel[channel])
)
channel.localClosed = True
if channel.localClosed and channel.remoteClosed:
self.channelClosed(channel)
# methods to override
def getChannel(self, channelType, windowSize, maxPacket, data):
"""
The other side requested a channel of some sort.
channelType is the type of channel being requested,
windowSize is the initial size of the remote window,
maxPacket is the largest packet we should send,
data is any other packet data (often nothing).
We return a subclass of L{SSHChannel}.
By default, this dispatches to a method 'channel_channelType' with any
non-alphanumerics in the channelType replace with _'s. If it cannot
find a suitable method, it returns an OPEN_UNKNOWN_CHANNEL_TYPE error.
The method is called with arguments of windowSize, maxPacket, data.
@type channelType: L{bytes}
@type windowSize: L{int}
@type maxPacket: L{int}
@type data: L{bytes}
@rtype: subclass of L{SSHChannel}/L{tuple}
"""
self._log.debug("got channel {channelType!r} request", channelType=channelType)
if hasattr(self.transport, "avatar"): # this is a server!
chan = self.transport.avatar.lookupChannel(
channelType, windowSize, maxPacket, data
)
else:
channelType = channelType.translate(TRANSLATE_TABLE)
attr = "channel_%s" % nativeString(channelType)
f = getattr(self, attr, None)
if f is not None:
chan = f(windowSize, maxPacket, data)
else:
chan = None
if chan is None:
raise error.ConchError("unknown channel", OPEN_UNKNOWN_CHANNEL_TYPE)
else:
chan.conn = self
return chan
def gotGlobalRequest(self, requestType, data):
"""
We got a global request. pretty much, this is just used by the client
to request that we forward a port from the server to the client.
Returns either:
- 1: request accepted
- 1, <data>: request accepted with request specific data
- 0: request denied
By default, this dispatches to a method 'global_requestType' with
-'s in requestType replaced with _'s. The found method is passed data.
If this method cannot be found, this method returns 0. Otherwise, it
returns the return value of that method.
@type requestType: L{bytes}
@type data: L{bytes}
@rtype: L{int}/L{tuple}
"""
self._log.debug("got global {requestType} request", requestType=requestType)
if hasattr(self.transport, "avatar"): # this is a server!
return self.transport.avatar.gotGlobalRequest(requestType, data)
requestType = nativeString(requestType.replace(b"-", b"_"))
f = getattr(self, "global_%s" % requestType, None)
if not f:
return 0
return f(data)
def channelClosed(self, channel):
"""
Called when a channel is closed.
It clears the local state related to the channel, and calls
channel.closed().
MAKE SURE YOU CALL THIS METHOD, even if you subclass L{SSHConnection}.
If you don't, things will break mysteriously.
@type channel: L{SSHChannel}
"""
if channel in self.channelsToRemoteChannel: # actually open
channel.localClosed = channel.remoteClosed = True
del self.localToRemoteChannel[channel.id]
del self.channels[channel.id]
del self.channelsToRemoteChannel[channel]
for d in self.deferreds.pop(channel.id, []):
d.errback(error.ConchError("Channel closed."))
channel.closed()
MSG_GLOBAL_REQUEST = 80
MSG_REQUEST_SUCCESS = 81
MSG_REQUEST_FAILURE = 82
MSG_CHANNEL_OPEN = 90
MSG_CHANNEL_OPEN_CONFIRMATION = 91
MSG_CHANNEL_OPEN_FAILURE = 92
MSG_CHANNEL_WINDOW_ADJUST = 93
MSG_CHANNEL_DATA = 94
MSG_CHANNEL_EXTENDED_DATA = 95
MSG_CHANNEL_EOF = 96
MSG_CHANNEL_CLOSE = 97
MSG_CHANNEL_REQUEST = 98
MSG_CHANNEL_SUCCESS = 99
MSG_CHANNEL_FAILURE = 100
OPEN_ADMINISTRATIVELY_PROHIBITED = 1
OPEN_CONNECT_FAILED = 2
OPEN_UNKNOWN_CHANNEL_TYPE = 3
OPEN_RESOURCE_SHORTAGE = 4
# From RFC 4254
EXTENDED_DATA_STDERR = 1
messages = {}
for name, value in locals().copy().items():
if name[:4] == "MSG_":
messages[value] = name # Doesn't handle doubles
alphanums = networkString(string.ascii_letters + string.digits)
TRANSLATE_TABLE = bytes(i if i in alphanums else ord("_") for i in range(256))
SSHConnection.protocolMessages = messages

View File

@@ -0,0 +1,129 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
A Factory for SSH servers.
See also L{twisted.conch.openssh_compat.factory} for OpenSSH compatibility.
Maintainer: Paul Swartz
"""
import random
from itertools import chain
from typing import Dict, List, Optional, Tuple
from twisted.conch import error
from twisted.conch.ssh import _kex, connection, transport, userauth
from twisted.internet import protocol
from twisted.logger import Logger
class SSHFactory(protocol.Factory):
"""
A Factory for SSH servers.
"""
primes: Optional[Dict[int, List[Tuple[int, int]]]]
_log = Logger()
protocol = transport.SSHServerTransport
services = {
b"ssh-userauth": userauth.SSHUserAuthServer,
b"ssh-connection": connection.SSHConnection,
}
def startFactory(self) -> None:
"""
Check for public and private keys.
"""
if not hasattr(self, "publicKeys"):
self.publicKeys = self.getPublicKeys()
if not hasattr(self, "privateKeys"):
self.privateKeys = self.getPrivateKeys()
if not self.publicKeys or not self.privateKeys:
raise error.ConchError("no host keys, failing")
if not hasattr(self, "primes"):
self.primes = self.getPrimes()
def buildProtocol(self, addr):
"""
Create an instance of the server side of the SSH protocol.
@type addr: L{twisted.internet.interfaces.IAddress} provider
@param addr: The address at which the server will listen.
@rtype: L{twisted.conch.ssh.transport.SSHServerTransport}
@return: The built transport.
"""
t = protocol.Factory.buildProtocol(self, addr)
t.supportedPublicKeys = list(
chain.from_iterable(
key.supportedSignatureAlgorithms() for key in self.privateKeys.values()
)
)
if not self.primes:
self._log.info(
"disabling non-fixed-group key exchange algorithms "
"because we cannot find moduli file"
)
t.supportedKeyExchanges = [
kexAlgorithm
for kexAlgorithm in t.supportedKeyExchanges
if _kex.isFixedGroup(kexAlgorithm) or _kex.isEllipticCurve(kexAlgorithm)
]
return t
def getPublicKeys(self):
"""
Called when the factory is started to get the public portions of the
servers host keys. Returns a dictionary mapping SSH key types to
public key strings.
@rtype: L{dict}
"""
raise NotImplementedError("getPublicKeys unimplemented")
def getPrivateKeys(self):
"""
Called when the factory is started to get the private portions of the
servers host keys. Returns a dictionary mapping SSH key types to
L{twisted.conch.ssh.keys.Key} objects.
@rtype: L{dict}
"""
raise NotImplementedError("getPrivateKeys unimplemented")
def getPrimes(self) -> Optional[Dict[int, List[Tuple[int, int]]]]:
"""
Called when the factory is started to get Diffie-Hellman generators and
primes to use. Returns a dictionary mapping number of bits to lists of
tuple of (generator, prime).
"""
def getDHPrime(self, bits: int) -> Tuple[int, int]:
"""
Return a tuple of (g, p) for a Diffe-Hellman process, with p being as
close to C{bits} bits as possible.
"""
def keyfunc(i: int) -> int:
return abs(i - bits)
assert self.primes is not None, "Factory should have been started by now."
primesKeys = sorted(self.primes.keys(), key=keyfunc)
realBits = primesKeys[0]
return random.choice(self.primes[realBits])
def getService(self, transport, service):
"""
Return a class to use as a service for the given transport.
@type transport: L{transport.SSHServerTransport}
@type service: L{bytes}
@rtype: subclass of L{service.SSHService}
"""
if service == b"ssh-userauth" or hasattr(transport, "avatar"):
return self.services[service]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
This module contains the implementation of the TCP forwarding, which allows
clients and servers to forward arbitrary TCP data across the connection.
Maintainer: Paul Swartz
"""
import struct
from twisted.conch.ssh import channel, common
from twisted.internet import protocol, reactor
from twisted.internet.endpoints import HostnameEndpoint, connectProtocol
class SSHListenForwardingFactory(protocol.Factory):
def __init__(self, connection, hostport, klass):
self.conn = connection
self.hostport = hostport # tuple
self.klass = klass
def buildProtocol(self, addr):
channel = self.klass(conn=self.conn)
client = SSHForwardingClient(channel)
channel.client = client
addrTuple = (addr.host, addr.port)
channelOpenData = packOpen_direct_tcpip(self.hostport, addrTuple)
self.conn.openChannel(channel, channelOpenData)
return client
class SSHListenForwardingChannel(channel.SSHChannel):
def channelOpen(self, specificData):
self._log.info("opened forwarding channel {id}", id=self.id)
if len(self.client.buf) > 1:
b = self.client.buf[1:]
self.write(b)
self.client.buf = b""
def openFailed(self, reason):
self.closed()
def dataReceived(self, data):
self.client.transport.write(data)
def eofReceived(self):
self.client.transport.loseConnection()
def closed(self):
if hasattr(self, "client"):
self._log.info("closing local forwarding channel {id}", id=self.id)
self.client.transport.loseConnection()
del self.client
class SSHListenClientForwardingChannel(SSHListenForwardingChannel):
name = b"direct-tcpip"
class SSHListenServerForwardingChannel(SSHListenForwardingChannel):
name = b"forwarded-tcpip"
class SSHConnectForwardingChannel(channel.SSHChannel):
"""
Channel used for handling server side forwarding request.
It acts as a client for the remote forwarding destination.
@ivar hostport: C{(host, port)} requested by client as forwarding
destination.
@type hostport: L{tuple} or a C{sequence}
@ivar client: Protocol connected to the forwarding destination.
@type client: L{protocol.Protocol}
@ivar clientBuf: Data received while forwarding channel is not yet
connected.
@type clientBuf: L{bytes}
@var _reactor: Reactor used for TCP connections.
@type _reactor: A reactor.
@ivar _channelOpenDeferred: Deferred used in testing to check the
result of C{channelOpen}.
@type _channelOpenDeferred: L{twisted.internet.defer.Deferred}
"""
_reactor = reactor
def __init__(self, hostport, *args, **kw):
channel.SSHChannel.__init__(self, *args, **kw)
self.hostport = hostport
self.client = None
self.clientBuf = b""
def channelOpen(self, specificData):
"""
See: L{channel.SSHChannel}
"""
self._log.info(
"connecting to {host}:{port}", host=self.hostport[0], port=self.hostport[1]
)
ep = HostnameEndpoint(self._reactor, self.hostport[0], self.hostport[1])
d = connectProtocol(ep, SSHForwardingClient(self))
d.addCallbacks(self._setClient, self._close)
self._channelOpenDeferred = d
def _setClient(self, client):
"""
Called when the connection was established to the forwarding
destination.
@param client: Client protocol connected to the forwarding destination.
@type client: L{protocol.Protocol}
"""
self.client = client
self._log.info(
"connected to {host}:{port}", host=self.hostport[0], port=self.hostport[1]
)
if self.clientBuf:
self.client.transport.write(self.clientBuf)
self.clientBuf = None
if self.client.buf[1:]:
self.write(self.client.buf[1:])
self.client.buf = b""
def _close(self, reason):
"""
Called when failed to connect to the forwarding destination.
@param reason: Reason why connection failed.
@type reason: L{twisted.python.failure.Failure}
"""
self._log.error(
"failed to connect to {host}:{port}: {reason}",
host=self.hostport[0],
port=self.hostport[1],
reason=reason,
)
self.loseConnection()
def dataReceived(self, data):
"""
See: L{channel.SSHChannel}
"""
if self.client:
self.client.transport.write(data)
else:
self.clientBuf += data
def closed(self):
"""
See: L{channel.SSHChannel}
"""
if self.client:
self._log.info("closed remote forwarding channel {id}", id=self.id)
if self.client.channel:
self.loseConnection()
self.client.transport.loseConnection()
del self.client
def openConnectForwardingClient(remoteWindow, remoteMaxPacket, data, avatar):
remoteHP, origHP = unpackOpen_direct_tcpip(data)
return SSHConnectForwardingChannel(
remoteHP,
remoteWindow=remoteWindow,
remoteMaxPacket=remoteMaxPacket,
avatar=avatar,
)
class SSHForwardingClient(protocol.Protocol):
def __init__(self, channel):
self.channel = channel
self.buf = b"\000"
def dataReceived(self, data):
if self.buf:
self.buf += data
else:
self.channel.write(data)
def connectionLost(self, reason):
if self.channel:
self.channel.loseConnection()
self.channel = None
def packOpen_direct_tcpip(destination, source):
"""
Pack the data suitable for sending in a CHANNEL_OPEN packet.
@type destination: L{tuple}
@param destination: A tuple of the (host, port) of the destination host.
@type source: L{tuple}
@param source: A tuple of the (host, port) of the source host.
"""
(connHost, connPort) = destination
(origHost, origPort) = source
if isinstance(connHost, str):
connHost = connHost.encode("utf-8")
if isinstance(origHost, str):
origHost = origHost.encode("utf-8")
conn = common.NS(connHost) + struct.pack(">L", connPort)
orig = common.NS(origHost) + struct.pack(">L", origPort)
return conn + orig
packOpen_forwarded_tcpip = packOpen_direct_tcpip
def unpackOpen_direct_tcpip(data):
"""Unpack the data to a usable format."""
connHost, rest = common.getNS(data)
if isinstance(connHost, bytes):
connHost = connHost.decode("utf-8")
connPort = int(struct.unpack(">L", rest[:4])[0])
origHost, rest = common.getNS(rest[4:])
if isinstance(origHost, bytes):
origHost = origHost.decode("utf-8")
origPort = int(struct.unpack(">L", rest[:4])[0])
return (connHost, connPort), (origHost, origPort)
unpackOpen_forwarded_tcpip = unpackOpen_direct_tcpip
def packGlobal_tcpip_forward(peer):
"""
Pack the data for tcpip forwarding.
@param peer: A tuple of the (host, port) .
@type peer: L{tuple}
"""
(host, port) = peer
return common.NS(host) + struct.pack(">L", port)
def unpackGlobal_tcpip_forward(data):
host, rest = common.getNS(data)
if isinstance(host, bytes):
host = host.decode("utf-8")
port = int(struct.unpack(">L", rest[:4])[0])
return host, port
"""This is how the data -> eof -> close stuff /should/ work.
debug3: channel 1: waiting for connection
debug1: channel 1: connected
debug1: channel 1: read<=0 rfd 7 len 0
debug1: channel 1: read failed
debug1: channel 1: close_read
debug1: channel 1: input open -> drain
debug1: channel 1: ibuf empty
debug1: channel 1: send eof
debug1: channel 1: input drain -> closed
debug1: channel 1: rcvd eof
debug1: channel 1: output open -> drain
debug1: channel 1: obuf empty
debug1: channel 1: close_write
debug1: channel 1: output drain -> closed
debug1: channel 1: rcvd close
debug3: channel 1: will not send data after close
debug1: channel 1: send close
debug1: channel 1: is dead
"""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
The parent class for all the SSH services. Currently implemented services
are ssh-userauth and ssh-connection.
Maintainer: Paul Swartz
"""
from typing import Dict
from twisted.logger import Logger
class SSHService:
# this is the ssh name for the service:
name: bytes = None # type:ignore[assignment]
protocolMessages: Dict[int, str] = {} # map #'s -> protocol names
transport = None # gets set later
_log = Logger()
def serviceStarted(self):
"""
called when the service is active on the transport.
"""
def serviceStopped(self):
"""
called when the service is stopped, either by the connection ending
or by another service being started
"""
def logPrefix(self):
return "SSHService {!r} on {}".format(
self.name, self.transport.transport.logPrefix()
)
def packetReceived(self, messageNum, packet):
"""
called when we receive a packet on the transport
"""
# print self.protocolMessages
if messageNum in self.protocolMessages:
messageType = self.protocolMessages[messageNum]
f = getattr(self, "ssh_%s" % messageType[4:], None)
if f is not None:
return f(packet)
self._log.info(
"couldn't handle {messageNum} {packet!r}",
messageNum=messageNum,
packet=packet,
)
self.transport.sendUnimplemented()

View File

@@ -0,0 +1,440 @@
# -*- test-case-name: twisted.conch.test.test_session -*-
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
"""
This module contains the implementation of SSHSession, which (by default)
allows access to a shell and a python interpreter over SSH.
Maintainer: Paul Swartz
"""
import os
import signal
import struct
import sys
from zope.interface import implementer
from twisted.conch.interfaces import (
EnvironmentVariableNotPermitted,
ISession,
ISessionSetEnv,
)
from twisted.conch.ssh import channel, common, connection
from twisted.internet import interfaces, protocol
from twisted.logger import Logger
from twisted.python.compat import networkString
log = Logger()
class SSHSession(channel.SSHChannel):
"""
A generalized implementation of an SSH session.
See RFC 4254, section 6.
The precise implementation of the various operations that the remote end
can send is left up to the avatar, usually via an adapter to an
interface such as L{ISession}.
@ivar buf: a buffer for data received before making a connection to a
client.
@type buf: L{bytes}
@ivar client: a protocol for communication with a shell, an application
program, or a subsystem (see RFC 4254, section 6.5).
@type client: L{SSHSessionProcessProtocol}
@ivar session: an object providing concrete implementations of session
operations.
@type session: L{ISession}
"""
name = b"session"
def __init__(self, *args, **kw):
channel.SSHChannel.__init__(self, *args, **kw)
self.buf = b""
self.client = None
self.session = None
def request_subsystem(self, data):
subsystem, ignored = common.getNS(data)
log.info('Asking for subsystem "{subsystem}"', subsystem=subsystem)
client = self.avatar.lookupSubsystem(subsystem, data)
if client:
pp = SSHSessionProcessProtocol(self)
proto = wrapProcessProtocol(pp)
client.makeConnection(proto)
pp.makeConnection(wrapProtocol(client))
self.client = pp
return 1
else:
log.error("Failed to get subsystem")
return 0
def request_shell(self, data):
log.info("Getting shell")
if not self.session:
self.session = ISession(self.avatar)
try:
pp = SSHSessionProcessProtocol(self)
self.session.openShell(pp)
except Exception:
log.failure("Error getting shell")
return 0
else:
self.client = pp
return 1
def request_exec(self, data):
if not self.session:
self.session = ISession(self.avatar)
f, data = common.getNS(data)
log.info('Executing command "{f}"', f=f)
try:
pp = SSHSessionProcessProtocol(self)
self.session.execCommand(pp, f)
except Exception:
log.failure('Error executing command "{f}"', f=f)
return 0
else:
self.client = pp
return 1
def request_pty_req(self, data):
if not self.session:
self.session = ISession(self.avatar)
term, windowSize, modes = parseRequest_pty_req(data)
log.info(
"Handling pty request: {term!r} {windowSize!r}",
term=term,
windowSize=windowSize,
)
try:
self.session.getPty(term, windowSize, modes)
except Exception:
log.failure("Error handling pty request")
return 0
else:
return 1
def request_env(self, data):
"""
Process a request to pass an environment variable.
@param data: The environment variable name and value, each encoded
as an SSH protocol string and concatenated.
@type data: L{bytes}
@return: A true value if the request to pass this environment
variable was accepted, otherwise a false value.
"""
if not self.session:
self.session = ISession(self.avatar)
if not ISessionSetEnv.providedBy(self.session):
return 0
name, value, data = common.getNS(data, 2)
try:
self.session.setEnv(name, value)
except EnvironmentVariableNotPermitted:
return 0
except Exception:
log.failure("Error setting environment variable {name}", name=name)
return 0
else:
return 1
def request_window_change(self, data):
if not self.session:
self.session = ISession(self.avatar)
winSize = parseRequest_window_change(data)
try:
self.session.windowChanged(winSize)
except Exception:
log.failure("Error changing window size")
return 0
else:
return 1
def dataReceived(self, data):
if not self.client:
# self.conn.sendClose(self)
self.buf += data
return
self.client.transport.write(data)
def extReceived(self, dataType, data):
if dataType == connection.EXTENDED_DATA_STDERR:
if self.client and hasattr(self.client.transport, "writeErr"):
self.client.transport.writeErr(data)
else:
log.warn("Weird extended data: {dataType}", dataType=dataType)
def eofReceived(self):
# If we have a session, tell it that EOF has been received and
# expect it to send a close message (it may need to send other
# messages such as exit-status or exit-signal first). If we don't
# have a session, then just send a close message directly.
if self.session:
self.session.eofReceived()
elif self.client:
self.conn.sendClose(self)
def closed(self):
if self.client and self.client.transport:
self.client.transport.loseConnection()
if self.session:
self.session.closed()
# def closeReceived(self):
# self.loseConnection() # don't know what to do with this
def loseConnection(self):
if self.client:
self.client.transport.loseConnection()
channel.SSHChannel.loseConnection(self)
class _ProtocolWrapper(protocol.ProcessProtocol):
"""
This class wraps a L{Protocol} instance in a L{ProcessProtocol} instance.
"""
def __init__(self, proto):
self.proto = proto
def connectionMade(self):
self.proto.connectionMade()
def outReceived(self, data):
self.proto.dataReceived(data)
def processEnded(self, reason):
self.proto.connectionLost(reason)
class _DummyTransport:
def __init__(self, proto):
self.proto = proto
def dataReceived(self, data):
self.proto.transport.write(data)
def write(self, data):
self.proto.dataReceived(data)
def writeSequence(self, seq):
self.write(b"".join(seq))
def loseConnection(self):
self.proto.connectionLost(protocol.connectionDone)
def wrapProcessProtocol(inst):
if isinstance(inst, protocol.Protocol):
return _ProtocolWrapper(inst)
else:
return inst
def wrapProtocol(proto):
return _DummyTransport(proto)
# SUPPORTED_SIGNALS is a list of signals that every session channel is supposed
# to accept. See RFC 4254
SUPPORTED_SIGNALS = [
"ABRT",
"ALRM",
"FPE",
"HUP",
"ILL",
"INT",
"KILL",
"PIPE",
"QUIT",
"SEGV",
"TERM",
"USR1",
"USR2",
]
@implementer(interfaces.ITransport)
class SSHSessionProcessProtocol(protocol.ProcessProtocol):
"""I am both an L{IProcessProtocol} and an L{ITransport}.
I am a transport to the remote endpoint and a process protocol to the
local subsystem.
"""
# once initialized, a dictionary mapping signal values to strings
# that follow RFC 4254.
_signalValuesToNames = None
def __init__(self, session):
self.session = session
self.lostOutOrErrFlag = False
def connectionMade(self):
if self.session.buf:
self.transport.write(self.session.buf)
self.session.buf = None
def outReceived(self, data):
self.session.write(data)
def errReceived(self, err):
self.session.writeExtended(connection.EXTENDED_DATA_STDERR, err)
def outConnectionLost(self):
"""
EOF should only be sent when both STDOUT and STDERR have been closed.
"""
if self.lostOutOrErrFlag:
self.session.conn.sendEOF(self.session)
else:
self.lostOutOrErrFlag = True
def errConnectionLost(self):
"""
See outConnectionLost().
"""
self.outConnectionLost()
def connectionLost(self, reason=None):
self.session.loseConnection()
def _getSignalName(self, signum):
"""
Get a signal name given a signal number.
"""
if self._signalValuesToNames is None:
self._signalValuesToNames = {}
# make sure that the POSIX ones are the defaults
for signame in SUPPORTED_SIGNALS:
signame = "SIG" + signame
sigvalue = getattr(signal, signame, None)
if sigvalue is not None:
self._signalValuesToNames[sigvalue] = signame
for k, v in signal.__dict__.items():
# Check for platform specific signals, ignoring Python specific
# SIG_DFL and SIG_IGN
if k.startswith("SIG") and not k.startswith("SIG_"):
if v not in self._signalValuesToNames:
self._signalValuesToNames[v] = k + "@" + sys.platform
return self._signalValuesToNames[signum]
def processEnded(self, reason=None):
"""
When we are told the process ended, try to notify the other side about
how the process ended using the exit-signal or exit-status requests.
Also, close the channel.
"""
if reason is not None:
err = reason.value
if err.signal is not None:
signame = self._getSignalName(err.signal)
if getattr(os, "WCOREDUMP", None) is not None and os.WCOREDUMP(
err.status
):
log.info("exitSignal: {signame} (core dumped)", signame=signame)
coreDumped = True
else:
log.info("exitSignal: {}", signame=signame)
coreDumped = False
self.session.conn.sendRequest(
self.session,
b"exit-signal",
common.NS(networkString(signame[3:]))
+ (b"\1" if coreDumped else b"\0")
+ common.NS(b"")
+ common.NS(b""),
)
elif err.exitCode is not None:
log.info("exitCode: {exitCode!r}", exitCode=err.exitCode)
self.session.conn.sendRequest(
self.session, b"exit-status", struct.pack(">L", err.exitCode)
)
self.session.loseConnection()
def getHost(self):
"""
Return the host from my session's transport.
"""
return self.session.conn.transport.getHost()
def getPeer(self):
"""
Return the peer from my session's transport.
"""
return self.session.conn.transport.getPeer()
def write(self, data):
self.session.write(data)
def writeSequence(self, seq):
self.session.write(b"".join(seq))
def loseConnection(self):
self.session.loseConnection()
class SSHSessionClient(protocol.Protocol):
def dataReceived(self, data):
if self.transport:
self.transport.write(data)
# methods factored out to make live easier on server writers
def parseRequest_pty_req(data):
"""Parse the data from a pty-req request into usable data.
@returns: a tuple of (terminal type, (rows, cols, xpixel, ypixel), modes)
"""
term, rest = common.getNS(data)
cols, rows, xpixel, ypixel = struct.unpack(">4L", rest[:16])
modes, ignored = common.getNS(rest[16:])
winSize = (rows, cols, xpixel, ypixel)
modes = [
(ord(modes[i : i + 1]), struct.unpack(">L", modes[i + 1 : i + 5])[0])
for i in range(0, len(modes) - 1, 5)
]
return term, winSize, modes
def packRequest_pty_req(term, geometry, modes):
"""
Pack a pty-req request so that it is suitable for sending.
NOTE: modes must be packed before being sent here.
@type geometry: L{tuple}
@param geometry: A tuple of (rows, columns, xpixel, ypixel)
"""
(rows, cols, xpixel, ypixel) = geometry
termPacked = common.NS(term)
winSizePacked = struct.pack(">4L", cols, rows, xpixel, ypixel)
modesPacked = common.NS(modes) # depend on the client packing modes
return termPacked + winSizePacked + modesPacked
def parseRequest_window_change(data):
"""Parse the data from a window-change request into usuable data.
@returns: a tuple of (rows, cols, xpixel, ypixel)
"""
cols, rows, xpixel, ypixel = struct.unpack(">4L", data)
return rows, cols, xpixel, ypixel
def packRequest_window_change(geometry):
"""
Pack a window-change request so that it is suitable for sending.
@type geometry: L{tuple}
@param geometry: A tuple of (rows, columns, xpixel, ypixel)
"""
(rows, cols, xpixel, ypixel) = geometry
return struct.pack(">4L", cols, rows, xpixel, ypixel)

View File

@@ -0,0 +1,40 @@
# Copyright (c) Twisted Matrix Laboratories.
# See LICENSE for details.
def parse(s):
s = s.strip()
expr = []
while s:
if s[0:1] == b"(":
newSexp = []
if expr:
expr[-1].append(newSexp)
expr.append(newSexp)
s = s[1:]
continue
if s[0:1] == b")":
aList = expr.pop()
s = s[1:]
if not expr:
assert not s
return aList
continue
i = 0
while s[i : i + 1].isdigit():
i += 1
assert i
length = int(s[:i])
data = s[i + 1 : i + 1 + length]
expr[-1].append(data)
s = s[i + 1 + length :]
assert False, "this should not happen"
def pack(sexp):
return b"".join(
b"(%b)" % (pack(o),)
if type(o) in (type(()), type([]))
else b"%d:%b" % (len(o), o)
for o in sexp
)

Some files were not shown because too many files have changed in this diff Show More