logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git

Interactive-SSH-programs.md (8709B)


  1. ---
  2. date: 2019-09-02
  3. layout: post
  4. title: Building interactive SSH applications
  5. tags: [ssh, informational, sourcehut]
  6. ---
  7. After the announcement of [shell access for builds.sr.ht jobs][builds
  8. announcement], a few people sent me some questions, wondering how this sort of
  9. thing is done. Writing interactive SSH applications is actually pretty easy, but
  10. it does require some knowledge of the pieces involved and a little bit of
  11. general Unix literacy.
  12. [builds announcement]: https://drewdevault.com/2019/08/19/Introducing-shell-access-for-builds.html
  13. On the server, there are three steps which you can meddle with using OpenSSH:
  14. authentication, the shell session, and the command. The shell is pretty easily
  15. manipulated. For example, if you set the user's login shell to
  16. `/usr/bin/nethack`, then [nethack][nethack] will run when they log in. Editing
  17. this is pretty straightforward, just pop open `/etc/passwd` as root and set
  18. their shell to your desired binary. If the user SSHes into your server with a
  19. TTY allocated (which is done by default), then you'll be able to run a curses
  20. application or something interactive.
  21. [nethack]: https://www.nethack.org/
  22. <script
  23. id="asciicast-CQ5iaFl8kMnOGV3x0TeI7vfjV"
  24. src="https://asciinema.org/a/pafXXANiWHY9MOH2yXdVHHJRd.js" async
  25. ></script>
  26. <noscript><i>This article includes third-party JavaScript content from
  27. asciinema.org, a free- and open-source platform that I trust.</i></noscript>
  28. However, a downside to this is that, if you choose a "shell" which does not
  29. behave like a shell, it will break when the user passes additional command line
  30. arguments, such as `ssh user@host ls -a`. To address this, instead of overriding
  31. the shell, we can override the *command* which is run. The best place to do this
  32. is in the user's `authorized_keys` file. Before each line, you can add options
  33. which apply to users who log in with that key. One of these options is the
  34. "command" option. If you add this to `/home/user/.ssh/authorized_keys` instead:
  35. ```
  36. command="/usr/bin/nethack" ssh-rsa ... user
  37. ```
  38. Then it'll use the user's shell (which should probably be `/bin/sh`) to run
  39. `nethack`, which will work regardless of the command supplied by the user (which
  40. is stored into `SSH_ORIGINAL_COMMAND` in the environment, should you need it).
  41. There are probably some other options you want to set here, as well, for
  42. security reasons:
  43. ```
  44. restrict,pty,command="..." ssh-rsa ... user
  45. ```
  46. The full list of options you can set here is available in the `sshd(8)` man
  47. page. `restrict` just turns off most stuff by default, and `pty` explicitly
  48. re-enables TTY allocation, so that we can do things like curses. This will work
  49. if you want to explicitly authorize specific people, one at a time, in your
  50. `authorized_keys` file, to use your SSH-driven application. However, there's
  51. one more place where we can meddle: the `AuthorizedKeysCommand` in
  52. `/etc/ssh/sshd_config`. Instead of having OpenSSH read from the
  53. `authorized_keys` file in the user's home directory, it can execute an arbitrary
  54. program and read the `authorized_keys` file from its stdout. For example, on
  55. Sourcehut we use something like this:
  56. ```
  57. AuthorizedKeysCommand /usr/bin/gitsrht-dispatch "%u" "%h" "%t" "%k"
  58. AuthorizedKeysUser root
  59. ```
  60. Respectively, these format strings will supply the command with the username
  61. attempting login, the user's home directory, the type of key in use (e.g.
  62. `ssh-rsa`), and the base64-encoded key itself. More options are available - see
  63. `TOKENS`, in the `sshd_config(8)` man page. The key supplied here can be used to
  64. identify the user - on Sourcehut we look up their SSH key in the database. Then
  65. you can choose whether or not to admit the user based on any logic of your
  66. choosing, and print an appropriate `authorized_keys` to stdout. You can also
  67. take this opportunity to forward this information along to the command that gets
  68. executed, by appending them to the command option or by using the environment
  69. options.
  70. ## How this works on builds.sr.ht
  71. We use a somewhat complex system for incoming SSH connections, which I won't go
  72. into here - it's only necessary to support multiple SSH applications on the same
  73. server, like git.sr.ht and builds.sr.ht. For builds.sr.ht, we accept all
  74. connections and authenticate later on. This means our AuthorizedKeysCommand is
  75. quite simple:
  76. ```python
  77. #!/usr/bin/env python3
  78. # We just let everyone in at this stage, authentication is done later on.
  79. import sys
  80. key_type = sys.argv[3]
  81. b64key = sys.argv[4]
  82. keys = (f"command=\"buildsrht-shell '{b64key}'\",restrict,pty " +
  83. f"{key_type} {b64key} somebody\n")
  84. print(keys)
  85. sys.exit(0)
  86. ```
  87. The command, `buildsrht-shell`, does some more interesting stuff. First, the
  88. user is told to connect with a command like `ssh builds@buildhost connect <job
  89. ID>`, so we use the `SSH_ORIGINAL_COMMAND` variable to grab the command line
  90. they included:
  91. ```python
  92. cmd = os.environ.get("SSH_ORIGINAL_COMMAND") or ""
  93. cmd = shlex.split(cmd)
  94. if len(cmd) != 2:
  95. fail("Usage: ssh ... connect <job ID>")
  96. op = cmd[0]
  97. if op not in ["connect", "tail"]:
  98. fail("Usage: ssh ... connect <job ID>")
  99. job_id = int(cmd[1])
  100. ```
  101. Then we do some authentication, fetching the job info from the local job runner
  102. and checking their key against meta.sr.ht (the authentication service).
  103. ```python
  104. b64key = sys.argv[1]
  105. def get_info(job_id):
  106. r = requests.get(f"http://localhost:8080/job/{job_id}/info")
  107. if r.status_code != 200:
  108. return None
  109. return r.json()
  110. info = get_info(job_id)
  111. if not info:
  112. fail("No such job found.")
  113. meta_origin = get_origin("meta.sr.ht")
  114. r = requests.get(f"{meta_origin}/api/ssh-key/{b64key}")
  115. if r.status_code == 200:
  116. username = r.json()["owner"]["name"]
  117. elif r.status_code == 404:
  118. fail("We don't recognize your SSH key. Make sure you've added it to " +
  119. f"your account.\n{get_origin('meta.sr.ht', external=True)}/keys")
  120. else:
  121. fail("Temporary authentication failure. Try again later.")
  122. if username != info["username"]:
  123. fail("You are not permitted to connect to this job.")
  124. ```
  125. There are two modes from here on out: connecting and tailing. The former logs
  126. into the local build VM, and the latter prints the logs to the terminal.
  127. Connecting looks like this:
  128. ```python
  129. def connect(job_id, info):
  130. """Opens a shell on the build VM"""
  131. limit = naturaltime(datetime.utcnow() - deadline)
  132. print(f"Your VM will be terminated {limit}, or when you log out.")
  133. print()
  134. requests.post(f"http://localhost:8080/job/{job_id}/claim")
  135. sys.stdout.flush()
  136. sys.stderr.flush()
  137. tty = os.open("/dev/tty", os.O_RDWR)
  138. os.dup2(0, tty)
  139. subprocess.call([
  140. "ssh", "-qt",
  141. "-p", str(info["port"]),
  142. "-o", "UserKnownHostsFile=/dev/null",
  143. "-o", "StrictHostKeyChecking=no",
  144. "-o", "LogLevel=quiet",
  145. "build@localhost", "bash"
  146. ])
  147. requests.post(f"http://localhost:8080/job/{job_id}/terminate")
  148. ```
  149. This is pretty self explanatory, except perhaps for the dup2 - we just open
  150. `/dev/tty` and make `stdin` a copy of it. Some interactive applications
  151. misbehave if stdin is not a tty, and this mimics the normal behavior of SSH.
  152. Then we log into the build VM over SSH, which with stdin/stdout/stderr rigged up
  153. like so will allow the user to interact with the build VM. After that completes,
  154. we terminate the VM.
  155. This is mostly plumbing work that just serves to get the user from point A to
  156. point B. The tail functionality is more application-like:
  157. ```python
  158. def tail(job_id, info):
  159. """Tails the build logs to stdout"""
  160. logs = os.path.join(cfg("builds.sr.ht::worker", "buildlogs"), str(job_id))
  161. p = subprocess.Popen(["tail", "-f", os.path.join(logs, "log")])
  162. tasks = set()
  163. procs = [p]
  164. # holy bejeezus this is hacky
  165. while True:
  166. for task in manifest.tasks:
  167. if task.name in tasks:
  168. continue
  169. path = os.path.join(logs, task.name, "log")
  170. if os.path.exists(path):
  171. procs.append(subprocess.Popen(
  172. f"tail -f {shlex.quote(path)} | " +
  173. "awk '{ print \"[" + shlex.quote(task.name) + "] \" $0 }'",
  174. shell=True))
  175. tasks.update({ task.name })
  176. info = get_info(job_id)
  177. if not info:
  178. break
  179. if info["task"] == info["tasks"]:
  180. for p in procs:
  181. p.kill()
  182. break
  183. time.sleep(3)
  184. if op == "connect":
  185. if info["task"] != info["tasks"] and info["status"] == "running":
  186. tail(job_id, info)
  187. connect(job_id, info)
  188. elif op == "tail":
  189. tail(job_id, info)
  190. ```
  191. This... I... let's just pretend you never saw this. And that's how SSH access to
  192. builds.sr.ht works!