1# Copyright (c) Twisted Matrix Laboratories.
2# See LICENSE for details.
3
4"""
5A UNIX SSH server.
6"""
7
8import fcntl
9import grp
10import os
11import pty
12import pwd
13import socket
14import struct
15import time
16import tty
17
18from zope.interface import implementer
19
20from twisted.conch import ttymodes
21from twisted.conch.avatar import ConchUser
22from twisted.conch.error import ConchError
23from twisted.conch.interfaces import ISession, ISFTPFile, ISFTPServer
24from twisted.conch.ls import lsLine
25from twisted.conch.ssh import filetransfer, forwarding, session
26from twisted.conch.ssh.filetransfer import (
27    FXF_APPEND,
28    FXF_CREAT,
29    FXF_EXCL,
30    FXF_READ,
31    FXF_TRUNC,
32    FXF_WRITE,
33)
34from twisted.cred import portal
35from twisted.internet.error import ProcessExitedAlready
36from twisted.logger import Logger
37from twisted.python import components
38from twisted.python.compat import nativeString
39
40try:
41    import utmp  # type: ignore[import]
42except ImportError:
43    utmp = None
44
45
46@implementer(portal.IRealm)
47class UnixSSHRealm:
48    def requestAvatar(self, username, mind, *interfaces):
49        user = UnixConchUser(username)
50        return interfaces[0], user, user.logout
51
52
53class UnixConchUser(ConchUser):
54    def __init__(self, username):
55        ConchUser.__init__(self)
56        self.username = username
57        self.pwdData = pwd.getpwnam(self.username)
58        l = [self.pwdData[3]]
59        for groupname, password, gid, userlist in grp.getgrall():
60            if username in userlist:
61                l.append(gid)
62        self.otherGroups = l
63        self.listeners = {}  # Dict mapping (interface, port) -> listener
64        self.channelLookup.update(
65            {
66                b"session": session.SSHSession,
67                b"direct-tcpip": forwarding.openConnectForwardingClient,
68            }
69        )
70
71        self.subsystemLookup.update({b"sftp": filetransfer.FileTransferServer})
72
73    def getUserGroupId(self):
74        return self.pwdData[2:4]
75
76    def getOtherGroups(self):
77        return self.otherGroups
78
79    def getHomeDir(self):
80        return self.pwdData[5]
81
82    def getShell(self):
83        return self.pwdData[6]
84
85    def global_tcpip_forward(self, data):
86        hostToBind, portToBind = forwarding.unpackGlobal_tcpip_forward(data)
87        from twisted.internet import reactor
88
89        try:
90            listener = self._runAsUser(
91                reactor.listenTCP,
92                portToBind,
93                forwarding.SSHListenForwardingFactory(
94                    self.conn,
95                    (hostToBind, portToBind),
96                    forwarding.SSHListenServerForwardingChannel,
97                ),
98                interface=hostToBind,
99            )
100        except BaseException:
101            return 0
102        else:
103            self.listeners[(hostToBind, portToBind)] = listener
104            if portToBind == 0:
105                portToBind = listener.getHost()[2]  # The port
106                return 1, struct.pack(">L", portToBind)
107            else:
108                return 1
109
110    def global_cancel_tcpip_forward(self, data):
111        hostToBind, portToBind = forwarding.unpackGlobal_tcpip_forward(data)
112        listener = self.listeners.get((hostToBind, portToBind), None)
113        if not listener:
114            return 0
115        del self.listeners[(hostToBind, portToBind)]
116        self._runAsUser(listener.stopListening)
117        return 1
118
119    def logout(self):
120        # Remove all listeners.
121        for listener in self.listeners.values():
122            self._runAsUser(listener.stopListening)
123        self._log.info(
124            "avatar {username} logging out ({nlisteners})",
125            username=self.username,
126            nlisteners=len(self.listeners),
127        )
128
129    def _runAsUser(self, f, *args, **kw):
130        euid = os.geteuid()
131        egid = os.getegid()
132        groups = os.getgroups()
133        uid, gid = self.getUserGroupId()
134        os.setegid(0)
135        os.seteuid(0)
136        os.setgroups(self.getOtherGroups())
137        os.setegid(gid)
138        os.seteuid(uid)
139        try:
140            f = iter(f)
141        except TypeError:
142            f = [(f, args, kw)]
143        try:
144            for i in f:
145                func = i[0]
146                args = len(i) > 1 and i[1] or ()
147                kw = len(i) > 2 and i[2] or {}
148                r = func(*args, **kw)
149        finally:
150            os.setegid(0)
151            os.seteuid(0)
152            os.setgroups(groups)
153            os.setegid(egid)
154            os.seteuid(euid)
155        return r
156
157
158@implementer(ISession)
159class SSHSessionForUnixConchUser:
160    _log = Logger()
161
162    def __init__(self, avatar, reactor=None):
163        """
164        Construct an C{SSHSessionForUnixConchUser}.
165
166        @param avatar: The L{UnixConchUser} for whom this is an SSH session.
167        @param reactor: An L{IReactorProcess} used to handle shell and exec
168            requests. Uses the default reactor if None.
169        """
170        if reactor is None:
171            from twisted.internet import reactor
172        self._reactor = reactor
173        self.avatar = avatar
174        self.environ = {"PATH": "/bin:/usr/bin:/usr/local/bin"}
175        self.pty = None
176        self.ptyTuple = 0
177
178    def addUTMPEntry(self, loggedIn=1):
179        if not utmp:
180            return
181        ipAddress = self.avatar.conn.transport.transport.getPeer().host
182        (packedIp,) = struct.unpack("L", socket.inet_aton(ipAddress))
183        ttyName = self.ptyTuple[2][5:]
184        t = time.time()
185        t1 = int(t)
186        t2 = int((t - t1) * 1e6)
187        entry = utmp.UtmpEntry()
188        entry.ut_type = loggedIn and utmp.USER_PROCESS or utmp.DEAD_PROCESS
189        entry.ut_pid = self.pty.pid
190        entry.ut_line = ttyName
191        entry.ut_id = ttyName[-4:]
192        entry.ut_tv = (t1, t2)
193        if loggedIn:
194            entry.ut_user = self.avatar.username
195            entry.ut_host = socket.gethostbyaddr(ipAddress)[0]
196            entry.ut_addr_v6 = (packedIp, 0, 0, 0)
197        a = utmp.UtmpRecord(utmp.UTMP_FILE)
198        a.pututline(entry)
199        a.endutent()
200        b = utmp.UtmpRecord(utmp.WTMP_FILE)
201        b.pututline(entry)
202        b.endutent()
203
204    def getPty(self, term, windowSize, modes):
205        self.environ["TERM"] = term
206        self.winSize = windowSize
207        self.modes = modes
208        master, slave = pty.openpty()
209        ttyname = os.ttyname(slave)
210        self.environ["SSH_TTY"] = ttyname
211        self.ptyTuple = (master, slave, ttyname)
212
213    def openShell(self, proto):
214        if not self.ptyTuple:  # We didn't get a pty-req.
215            self._log.error("tried to get shell without pty, failing")
216            raise ConchError("no pty")
217        uid, gid = self.avatar.getUserGroupId()
218        homeDir = self.avatar.getHomeDir()
219        shell = self.avatar.getShell()
220        self.environ["USER"] = self.avatar.username
221        self.environ["HOME"] = homeDir
222        self.environ["SHELL"] = shell
223        shellExec = os.path.basename(shell)
224        peer = self.avatar.conn.transport.transport.getPeer()
225        host = self.avatar.conn.transport.transport.getHost()
226        self.environ["SSH_CLIENT"] = f"{peer.host} {peer.port} {host.port}"
227        self.getPtyOwnership()
228        self.pty = self._reactor.spawnProcess(
229            proto,
230            shell,
231            [f"-{shellExec}"],
232            self.environ,
233            homeDir,
234            uid,
235            gid,
236            usePTY=self.ptyTuple,
237        )
238        self.addUTMPEntry()
239        fcntl.ioctl(self.pty.fileno(), tty.TIOCSWINSZ, struct.pack("4H", *self.winSize))
240        if self.modes:
241            self.setModes()
242        self.oldWrite = proto.transport.write
243        proto.transport.write = self._writeHack
244        self.avatar.conn.transport.transport.setTcpNoDelay(1)
245
246    def execCommand(self, proto, cmd):
247        uid, gid = self.avatar.getUserGroupId()
248        homeDir = self.avatar.getHomeDir()
249        shell = self.avatar.getShell() or "/bin/sh"
250        self.environ["HOME"] = homeDir
251        command = (shell, "-c", cmd)
252        peer = self.avatar.conn.transport.transport.getPeer()
253        host = self.avatar.conn.transport.transport.getHost()
254        self.environ["SSH_CLIENT"] = f"{peer.host} {peer.port} {host.port}"
255        if self.ptyTuple:
256            self.getPtyOwnership()
257        self.pty = self._reactor.spawnProcess(
258            proto,
259            shell,
260            command,
261            self.environ,
262            homeDir,
263            uid,
264            gid,
265            usePTY=self.ptyTuple or 0,
266        )
267        if self.ptyTuple:
268            self.addUTMPEntry()
269            if self.modes:
270                self.setModes()
271        self.avatar.conn.transport.transport.setTcpNoDelay(1)
272
273    def getPtyOwnership(self):
274        ttyGid = os.stat(self.ptyTuple[2])[5]
275        uid, gid = self.avatar.getUserGroupId()
276        euid, egid = os.geteuid(), os.getegid()
277        os.setegid(0)
278        os.seteuid(0)
279        try:
280            os.chown(self.ptyTuple[2], uid, ttyGid)
281        finally:
282            os.setegid(egid)
283            os.seteuid(euid)
284
285    def setModes(self):
286        pty = self.pty
287        attr = tty.tcgetattr(pty.fileno())
288        for mode, modeValue in self.modes:
289            if mode not in ttymodes.TTYMODES:
290                continue
291            ttyMode = ttymodes.TTYMODES[mode]
292            if len(ttyMode) == 2:  # Flag.
293                flag, ttyAttr = ttyMode
294                if not hasattr(tty, ttyAttr):
295                    continue
296                ttyval = getattr(tty, ttyAttr)
297                if modeValue:
298                    attr[flag] = attr[flag] | ttyval
299                else:
300                    attr[flag] = attr[flag] & ~ttyval
301            elif ttyMode == "OSPEED":
302                attr[tty.OSPEED] = getattr(tty, f"B{modeValue}")
303            elif ttyMode == "ISPEED":
304                attr[tty.ISPEED] = getattr(tty, f"B{modeValue}")
305            else:
306                if not hasattr(tty, ttyMode):
307                    continue
308                ttyval = getattr(tty, ttyMode)
309                attr[tty.CC][ttyval] = bytes((modeValue,))
310        tty.tcsetattr(pty.fileno(), tty.TCSANOW, attr)
311
312    def eofReceived(self):
313        if self.pty:
314            self.pty.closeStdin()
315
316    def closed(self):
317        if self.ptyTuple and os.path.exists(self.ptyTuple[2]):
318            ttyGID = os.stat(self.ptyTuple[2])[5]
319            os.chown(self.ptyTuple[2], 0, ttyGID)
320        if self.pty:
321            try:
322                self.pty.signalProcess("HUP")
323            except (OSError, ProcessExitedAlready):
324                pass
325            self.pty.loseConnection()
326            self.addUTMPEntry(0)
327        self._log.info("shell closed")
328
329    def windowChanged(self, winSize):
330        self.winSize = winSize
331        fcntl.ioctl(self.pty.fileno(), tty.TIOCSWINSZ, struct.pack("4H", *self.winSize))
332
333    def _writeHack(self, data):
334        """
335        Hack to send ignore messages when we aren't echoing.
336        """
337        if self.pty is not None:
338            attr = tty.tcgetattr(self.pty.fileno())[3]
339            if not attr & tty.ECHO and attr & tty.ICANON:  # No echo.
340                self.avatar.conn.transport.sendIgnore("\x00" * (8 + len(data)))
341        self.oldWrite(data)
342
343
344@implementer(ISFTPServer)
345class SFTPServerForUnixConchUser:
346    def __init__(self, avatar):
347        self.avatar = avatar
348
349    def _setAttrs(self, path, attrs):
350        """
351        NOTE: this function assumes it runs as the logged-in user:
352        i.e. under _runAsUser()
353        """
354        if "uid" in attrs and "gid" in attrs:
355            os.chown(path, attrs["uid"], attrs["gid"])
356        if "permissions" in attrs:
357            os.chmod(path, attrs["permissions"])
358        if "atime" in attrs and "mtime" in attrs:
359            os.utime(path, (attrs["atime"], attrs["mtime"]))
360
361    def _getAttrs(self, s):
362        return {
363            "size": s.st_size,
364            "uid": s.st_uid,
365            "gid": s.st_gid,
366            "permissions": s.st_mode,
367            "atime": int(s.st_atime),
368            "mtime": int(s.st_mtime),
369        }
370
371    def _absPath(self, path):
372        home = self.avatar.getHomeDir()
373        return os.path.join(nativeString(home.path), nativeString(path))
374
375    def gotVersion(self, otherVersion, extData):
376        return {}
377
378    def openFile(self, filename, flags, attrs):
379        return UnixSFTPFile(self, self._absPath(filename), flags, attrs)
380
381    def removeFile(self, filename):
382        filename = self._absPath(filename)
383        return self.avatar._runAsUser(os.remove, filename)
384
385    def renameFile(self, oldpath, newpath):
386        oldpath = self._absPath(oldpath)
387        newpath = self._absPath(newpath)
388        return self.avatar._runAsUser(os.rename, oldpath, newpath)
389
390    def makeDirectory(self, path, attrs):
391        path = self._absPath(path)
392        return self.avatar._runAsUser(
393            [(os.mkdir, (path,)), (self._setAttrs, (path, attrs))]
394        )
395
396    def removeDirectory(self, path):
397        path = self._absPath(path)
398        self.avatar._runAsUser(os.rmdir, path)
399
400    def openDirectory(self, path):
401        return UnixSFTPDirectory(self, self._absPath(path))
402
403    def getAttrs(self, path, followLinks):
404        path = self._absPath(path)
405        if followLinks:
406            s = self.avatar._runAsUser(os.stat, path)
407        else:
408            s = self.avatar._runAsUser(os.lstat, path)
409        return self._getAttrs(s)
410
411    def setAttrs(self, path, attrs):
412        path = self._absPath(path)
413        self.avatar._runAsUser(self._setAttrs, path, attrs)
414
415    def readLink(self, path):
416        path = self._absPath(path)
417        return self.avatar._runAsUser(os.readlink, path)
418
419    def makeLink(self, linkPath, targetPath):
420        linkPath = self._absPath(linkPath)
421        targetPath = self._absPath(targetPath)
422        return self.avatar._runAsUser(os.symlink, targetPath, linkPath)
423
424    def realPath(self, path):
425        return os.path.realpath(self._absPath(path))
426
427    def extendedRequest(self, extName, extData):
428        raise NotImplementedError
429
430
431@implementer(ISFTPFile)
432class UnixSFTPFile:
433    def __init__(self, server, filename, flags, attrs):
434        self.server = server
435        openFlags = 0
436        if flags & FXF_READ == FXF_READ and flags & FXF_WRITE == 0:
437            openFlags = os.O_RDONLY
438        if flags & FXF_WRITE == FXF_WRITE and flags & FXF_READ == 0:
439            openFlags = os.O_WRONLY
440        if flags & FXF_WRITE == FXF_WRITE and flags & FXF_READ == FXF_READ:
441            openFlags = os.O_RDWR
442        if flags & FXF_APPEND == FXF_APPEND:
443            openFlags |= os.O_APPEND
444        if flags & FXF_CREAT == FXF_CREAT:
445            openFlags |= os.O_CREAT
446        if flags & FXF_TRUNC == FXF_TRUNC:
447            openFlags |= os.O_TRUNC
448        if flags & FXF_EXCL == FXF_EXCL:
449            openFlags |= os.O_EXCL
450        if "permissions" in attrs:
451            mode = attrs["permissions"]
452            del attrs["permissions"]
453        else:
454            mode = 0o777
455        fd = server.avatar._runAsUser(os.open, filename, openFlags, mode)
456        if attrs:
457            server.avatar._runAsUser(server._setAttrs, filename, attrs)
458        self.fd = fd
459
460    def close(self):
461        return self.server.avatar._runAsUser(os.close, self.fd)
462
463    def readChunk(self, offset, length):
464        return self.server.avatar._runAsUser(
465            [(os.lseek, (self.fd, offset, 0)), (os.read, (self.fd, length))]
466        )
467
468    def writeChunk(self, offset, data):
469        return self.server.avatar._runAsUser(
470            [(os.lseek, (self.fd, offset, 0)), (os.write, (self.fd, data))]
471        )
472
473    def getAttrs(self):
474        s = self.server.avatar._runAsUser(os.fstat, self.fd)
475        return self.server._getAttrs(s)
476
477    def setAttrs(self, attrs):
478        raise NotImplementedError
479
480
481class UnixSFTPDirectory:
482    def __init__(self, server, directory):
483        self.server = server
484        self.files = server.avatar._runAsUser(os.listdir, directory)
485        self.dir = directory
486
487    def __iter__(self):
488        return self
489
490    def __next__(self):
491        try:
492            f = self.files.pop(0)
493        except IndexError:
494            raise StopIteration
495        else:
496            s = self.server.avatar._runAsUser(os.lstat, os.path.join(self.dir, f))
497            longname = lsLine(f, s)
498            attrs = self.server._getAttrs(s)
499            return (f, longname, attrs)
500
501    next = __next__
502
503    def close(self):
504        self.files = []
505
506
507components.registerAdapter(
508    SFTPServerForUnixConchUser, UnixConchUser, filetransfer.ISFTPServer
509)
510components.registerAdapter(SSHSessionForUnixConchUser, UnixConchUser, session.ISession)
511