├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── tmate.ex └── tmate │ ├── daemon_tcp.ex │ ├── master_api.ex │ ├── protocol_defs.ex │ ├── session.ex │ ├── session_registry.ex │ ├── util │ ├── json_api.ex │ └── plug_verify_auth_token.ex │ ├── webhook.ex │ └── ws_api │ ├── internal_api.ex │ ├── router.ex │ └── websocket.ex ├── mix.exs ├── mix.lock ├── rel ├── config.exs ├── plugins │ └── .gitignore └── vm.args └── test ├── test_helper.exs └── tmate ├── session_test.exs ├── web_api_test.exs └── webhook_events_test.exs /.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | deps/ 3 | config/prod.secret.exs 4 | .deliver/ 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.9-alpine AS build 2 | 3 | RUN mix local.hex --force && mix local.rebar --force 4 | RUN apk --no-cache add git 5 | 6 | WORKDIR /build 7 | 8 | COPY mix.exs . 9 | COPY mix.lock . 10 | 11 | ENV MIX_ENV prod 12 | 13 | RUN mix deps.get 14 | RUN mix deps.compile 15 | 16 | COPY lib lib 17 | COPY test test 18 | COPY config config 19 | COPY rel rel 20 | 21 | RUN mix distillery.release --no-tar && \ 22 | mkdir _build/lib-layer && \ 23 | mv _build/prod/rel/tmate/lib/tmate* _build/lib-layer 24 | 25 | ### Minimal run-time image 26 | FROM alpine:3.9 27 | 28 | RUN apk --no-cache add ncurses-libs openssl ca-certificates bash 29 | 30 | RUN adduser -D app 31 | 32 | ENV MIX_ENV prod 33 | 34 | WORKDIR /opt/app 35 | 36 | # Copy release from build stage 37 | # We copy in two passes to benefit from docker layers 38 | # Note "COPY some_dir dst" will copy the content of some_dir into dst 39 | COPY --from=build /build/_build/prod/rel/* . 40 | COPY --from=build /build/_build/lib-layer lib/ 41 | 42 | USER app 43 | 44 | RUN mkdir /tmp/app 45 | ENV RELEASE_MUTABLE_DIR /tmp/app 46 | ENV REPLACE_OS_VARS true 47 | 48 | # Start command 49 | CMD ["/opt/app/bin/tmate", "foreground"] 50 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM elixir:1.9-alpine 2 | 3 | RUN mix local.hex --force && mix local.rebar --force 4 | RUN apk --no-cache add git 5 | 6 | WORKDIR /src/tmate-websocket 7 | 8 | COPY mix.exs . 9 | COPY mix.lock . 10 | 11 | ENV MIX_ENV dev 12 | 13 | RUN mix deps.get 14 | RUN mix deps.compile 15 | 16 | COPY lib lib 17 | COPY config config 18 | 19 | RUN mix compile 20 | 21 | CMD mkfifo console; sleep 1000d > console & cat console | \ 22 | iex --name websocket@session \ 23 | --cookie cookie \ 24 | -S mix 25 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, 4 | backends: [:console] 5 | 6 | config :logger, :console, 7 | level: :debug 8 | 9 | config :tmate, :daemon, 10 | ranch_opts: [port: 4002, max_connections: 10000], 11 | tmux_socket_path: "/tmp/tmate/sessions" 12 | 13 | config :tmate, :webhook, 14 | webhooks: [], 15 | max_attempts: 3, 16 | initial_retry_interval: 300 17 | 18 | 19 | import_config "#{Mix.env}.exs" 20 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, :console, 4 | format: "[$level] $message\n" 5 | 6 | config :tmate, :daemon, 7 | hmac_key: "VlCkxXLjzaFravvNSPpOdoAffaQHRNVHeSBNWUcfLDYTYHuaYQsWwyCjrSJAJUSr" 8 | 9 | config :tmate, :websocket, 10 | listener: :ranch_tcp, 11 | ranch_opts: [port: 4001], 12 | cowboy_opts: %{compress: true}, 13 | base_url: "ws://localhost:4001/" 14 | 15 | config :tmate, :webhook, 16 | webhooks: [ 17 | [url: "http://master:4000/internal_api/webhook", 18 | userdata: "internal_api_auth_token"]] 19 | 20 | config :tmate, :master, 21 | user_facing_base_url: "http://localhost:4000/", 22 | internal_api: [base_url: "http://master:4000/internal_api", 23 | auth_token: "internal_api_auth_token"] 24 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | # XXX The configuration file is evalated at compile time, 3 | # and re-evaluated at runtime. 4 | 5 | config :logger, :console, 6 | format: "$metadata[$level] $message\n", 7 | metadata: [:token, :id], 8 | level: :info 9 | 10 | config :tmate, :daemon, 11 | hmac_key: System.get_env("DAEMON_HMAC_KEY") 12 | 13 | websocket_ranch_opts = if System.get_env("SSL_KEY_FILE") do 14 | [listener: :ranch_ssl, 15 | ranch_opts: [ 16 | port: System.get_env("WEBSOCKET_PORT", "4001") |> String.to_integer(), 17 | keyfile: System.get_env("SSL_KEY_FILE"), 18 | certfile: System.get_env("SSL_CERT_FILE"), 19 | cacertfile: System.get_env("SSL_CACERT_FILE")]] 20 | else 21 | [listener: :ranch_tcp, 22 | ranch_opts: [port: System.get_env("WEBSOCKET_PORT", "4001") |> String.to_integer()]] 23 | end 24 | 25 | config :tmate, :websocket, Keyword.merge(websocket_ranch_opts, 26 | cowboy_opts: %{ 27 | compress: true, 28 | proxy_header: System.get_env("USE_PROXY_PROTOCOL") == "1"}, 29 | base_url: System.get_env("WEBSOCKET_BASE_URL") 30 | ) 31 | 32 | config :tzdata, :autoupdate, :disabled 33 | 34 | config :tmate, :webhook, 35 | webhooks: [ 36 | [url: "#{System.get_env("MASTER_BASE_URL")}internal_api/webhook", 37 | userdata: "#{System.get_env("INTERNAL_API_AUTH_TOKEN")}"]], 38 | max_attempts: 16, # ~2.7 hours of retries 39 | initial_retry_interval: 300 40 | 41 | config :tmate, :master, 42 | user_facing_base_url: System.get_env("USER_FACING_BASE_URL"), 43 | internal_api: [base_url: "#{System.get_env("MASTER_BASE_URL")}internal_api", 44 | auth_token: System.get_env("INTERNAL_API_AUTH_TOKEN")] 45 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, :console, 4 | level: :warn, 5 | format: "[$level] $message\n" 6 | 7 | config :tmate, :daemon, 8 | hmac_key: "key" 9 | 10 | config :tmate, :websocket, 11 | enabled: false 12 | 13 | config :tmate, :master, 14 | user_facing_base_url: "http://localhost:4000/", 15 | internal_api: [base_url: "", 16 | auth_token: "internal_api_auth_token"] 17 | -------------------------------------------------------------------------------- /lib/tmate.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate do 2 | use Application 3 | require Logger 4 | 5 | def host do 6 | node() |> to_string |> String.split("@") |> Enum.at(1) |> String.split(".") |> Enum.at(0) 7 | end 8 | 9 | def start(_type, _args) do 10 | import Supervisor.Spec 11 | 12 | {:ok, daemon_options} = Application.fetch_env(:tmate, :daemon) 13 | {:ok, websocket_options} = Application.fetch_env(:tmate, :websocket) 14 | {:ok, webhook_options} = Application.fetch_env(:tmate, :webhook) 15 | 16 | webhooks = webhook_options[:webhooks] |> Enum.map(& {Tmate.Webhook, &1}) 17 | registry = {Tmate.SessionRegistry, Tmate.SessionRegistry} 18 | session_opts = [webhooks: webhooks, registry: registry] 19 | 20 | children = [ 21 | :ranch.child_spec(:daemon_tcp, 3, :ranch_tcp, daemon_options[:ranch_opts], 22 | Tmate.DaemonTcp, session_opts), 23 | worker(Tmate.SessionRegistry, [[name: Tmate.SessionRegistry]]), 24 | ] 25 | 26 | children = unless websocket_options[:enabled] == false do 27 | cowboy_opts = Map.merge(%{env: %{dispatch: Tmate.WsApi.Router.cowboy_dispatch(session_opts)}}, 28 | websocket_options[:cowboy_opts]) 29 | children ++ [ 30 | :ranch.child_spec(:websocket_tcp, 3, websocket_options[:listener], websocket_options[:ranch_opts], 31 | :cowboy_clear, cowboy_opts) 32 | ] 33 | else 34 | children 35 | end 36 | 37 | Logger.info("Starting websocket server") 38 | Supervisor.start_link(children, [strategy: :one_for_one, name: Tmate.Supervisor]) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tmate/daemon_tcp.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.DaemonTcp do 2 | @behaviour :ranch_protocol 3 | require Logger 4 | use GenServer 5 | 6 | def start_link(ref, socket, transport, opts) do 7 | :proc_lib.start_link(__MODULE__, :init, [{ref, socket, transport, opts}]) 8 | end 9 | 10 | def init({ref, socket, transport, opts}) do 11 | :ok = :proc_lib.init_ack({:ok, self()}) 12 | 13 | :ok = :ranch.accept_ack(ref) 14 | :ok = transport.setopts(socket, [active: :once]) 15 | {:ok, session} = Tmate.Session.start_link(opts, 16 | {__MODULE__, {self(), socket, transport}}) 17 | 18 | state = %{socket: socket, transport: transport, session: session, mpac_buffer: <<>>} 19 | :gen_server.enter_loop(__MODULE__, [], state) 20 | end 21 | 22 | def handle_info({:tcp, socket, data}, 23 | state=%{socket: socket, transport: transport, mpac_buffer: mpac_buffer}) do 24 | :ok = transport.setopts(socket, [active: :once]) 25 | {:ok, state} = receive_data(state, mpac_buffer <> data) 26 | {:noreply, state} 27 | end 28 | 29 | def handle_info({:tcp_error, _socket, reason}, state) do 30 | Logger.warn("Daemon connection errored: #{reason}") 31 | {:stop, reason, state} 32 | end 33 | 34 | def handle_info({:tcp_closed, _socket}, state) do 35 | {:stop, {:shutdown, :tcp_closed}, state} 36 | end 37 | 38 | defp receive_data(state, data) do 39 | case MessagePack.unpack_once(data) do 40 | {:ok, {msg, rest}} -> 41 | :ok = Tmate.Session.notify_daemon_msg(state.session, msg) 42 | receive_data(state, rest) 43 | {:error, :incomplete} -> 44 | {:ok, %{state | mpac_buffer: data}} 45 | end 46 | end 47 | 48 | def send_msg({_pid, socket, transport}, msg) do 49 | # no need to go through the daemon process. 50 | # the caller process is linked to the daemon process anyways, 51 | # and the serialization doesn't need to be offloaded. 52 | transport.send(socket, MessagePack.pack!(msg, enable_string: true)) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/tmate/master_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.MasterApi do 2 | def internal_api_opts do 3 | # XXX We can't pass the auth token directly, it is not 4 | # necessarily defined at compile time. 5 | Application.fetch_env!(:tmate, :master)[:internal_api] 6 | end 7 | use Tmate.Util.JsonApi, fn_opts: &__MODULE__.internal_api_opts/0 8 | 9 | def enabled? do 10 | !!internal_api_opts() 11 | end 12 | 13 | def get_session(token) do 14 | case get("/session", [], params: %{token: token}) do 15 | {:ok, session} -> 16 | session = 17 | session 18 | |> with_atom_keys() 19 | |> as_timestamp(:disconnected_at) 20 | |> as_timestamp(:created_at) 21 | {:ok, session} 22 | {:error, 404} -> 23 | {:error, :not_found} 24 | {:error, reason} -> 25 | {:error, reason} 26 | end 27 | end 28 | 29 | def get_named_session_prefix(api_key) do 30 | case get("/named_session_prefix", [], params: %{api_key: api_key}) do 31 | {:ok, result} -> 32 | {:ok, result["prefix"]} 33 | {:error, 404} -> 34 | {:error, :not_found} 35 | {:error, reason} -> 36 | {:error, reason} 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/tmate/protocol_defs.ex: -------------------------------------------------------------------------------- 1 | # tmate-protocol.h 2 | 3 | defmodule Tmate.ProtocolDefs.Define do 4 | defmacro define(name, value) do 5 | quote do: (defmacro unquote(name), do: unquote(value)) 6 | end 7 | 8 | defmacro enum(_enum_name, values) do 9 | values |> Enum.with_index |> Enum.map(fn {name, index} -> 10 | quote do: (defmacro unquote(name), do: unquote(index)) 11 | end) 12 | end 13 | end 14 | 15 | defmodule Tmate.ProtocolDefs do 16 | import __MODULE__.Define 17 | 18 | define tmate_max_message_size, (17*1024) 19 | 20 | enum tmate_ws_out_msg_types, [ 21 | tmate_ws_daemon_out_msg, 22 | tmate_ws_snapshot, 23 | ] 24 | 25 | enum tmate_ws_in_msg_types, [ 26 | tmate_ws_pane_keys, 27 | tmate_ws_exec_cmd, 28 | tmate_ws_resize, 29 | ] 30 | 31 | enum tmate_control_out_msg_types, [ 32 | tmate_ctl_header, 33 | tmate_ctl_deamon_out_msg, 34 | tmate_ctl_snapshot, 35 | tmate_ctl_client_join, 36 | tmate_ctl_client_left, 37 | tmate_ctl_exec, 38 | tmate_ctl_latency, 39 | ] 40 | 41 | enum tmate_control_in_msg_types, [ 42 | tmate_ctl_deamon_fwd_msg, 43 | tmate_ctl_request_snapshot, 44 | tmate_ctl_pane_keys, 45 | tmate_ctl_resize, 46 | tmate_ctl_exec_response, 47 | tmate_ctl_rename_session, 48 | ] 49 | 50 | enum tmate_daemon_out_msg_types, [ 51 | tmate_out_header, 52 | tmate_out_sync_layout, 53 | tmate_out_pty_data, 54 | tmate_out_exec_cmd_str, 55 | tmate_out_failed_cmd, 56 | tmate_out_status, 57 | tmate_out_sync_copy_mode, 58 | tmate_out_write_copy_mode, 59 | tmate_out_fin, 60 | tmate_out_ready, 61 | tmate_out_reconnect, 62 | tmate_out_snapshot, 63 | tmate_out_exec_cmd, 64 | tmate_out_uname, 65 | ] 66 | 67 | enum tmate_daemon_in_msg_types, [ 68 | tmate_in_notify, 69 | tmate_in_pane_key, 70 | tmate_in_resize, 71 | tmate_in_exec_cmd_str, 72 | tmate_in_set_env, 73 | tmate_in_ready, 74 | tmate_in_exec_cmd, 75 | ] 76 | end 77 | -------------------------------------------------------------------------------- /lib/tmate/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Session do 2 | require Tmate.ProtocolDefs, as: P 3 | alias Tmate.WsApi.WebSocket 4 | 5 | use GenServer 6 | require Logger 7 | 8 | @max_snapshot_lines 300 9 | @latest_version "2.4.0" 10 | @upgrade_msg "See https://tmate.io for a list of new features" 11 | 12 | def start_link(session_opts, daemon, opts \\ []) do 13 | GenServer.start_link(__MODULE__, {session_opts, daemon}, opts) 14 | end 15 | 16 | def init({session_opts, daemon}) do 17 | [webhooks: webhooks, registry: registry] = session_opts 18 | 19 | state = %{webhooks: webhooks, registry: registry, 20 | daemon: daemon, initialized: false, 21 | ssh_only: false, foreground: false, 22 | init_state: nil, webhook_pids: [], 23 | pending_ws_subs: [], ws_subs: [], 24 | daemon_protocol_version: -1, 25 | current_layout: [], clients: %{}} 26 | 27 | Process.flag(:trap_exit, true) 28 | {:ok, state} 29 | end 30 | 31 | def handle_info({:timeout, _ref, {:notify_daemon, msg}}, state) do 32 | notify_daemon(state, msg) 33 | {:noreply, state} 34 | end 35 | 36 | def handle_info({:DOWN, _ref, _type, pid, _info}, state) do 37 | {:noreply, handle_ws_disconnect(state, pid)} 38 | end 39 | 40 | def handle_info({:EXIT, _linked_pid, reason}, state) do 41 | # We must handle EXIT signals that are coming from non parent pids. 42 | # In this case, we'll let terminate() deal with our cleanup. 43 | {:stop, reason, state} 44 | end 45 | 46 | def terminate(reason, %{initialized: true}=state) do 47 | # Note: the daemon connection and webhooks are linked processes. 48 | # Any exits different tham :kill from these processes will land us here. 49 | # We can also get a :stale exit request from the registery. 50 | # reason is typically: 51 | # * {:shutdown, :session_fin}: client sent a fin message. Session is closed. 52 | # * {:shutdown, :tcp_closed}: client disconnected. We can expect a reconnection. 53 | # * {:shutdown, :stale}: client reconencted, and this session is now stale. 54 | 55 | # We should put the following code in another process that monitors the 56 | # current process. This way, if the current process crashes, we would 57 | # still be able to send our disconnect message. But that's more work 58 | # to implement. 59 | case reason do 60 | {:shutdown, :session_fin} -> emit_event(state, :session_close) 61 | _ -> emit_event(state, :session_disconnect) 62 | end 63 | 64 | :ok 65 | end 66 | 67 | def terminate(_reason, _state) do 68 | :ok 69 | end 70 | 71 | def ws_verify_auth(session) do 72 | GenServer.call(session, {:ws_verify_auth}, :infinity) 73 | end 74 | 75 | def ws_request_sub(session, ws, client) do 76 | GenServer.call(session, {:ws_request_sub, ws, client}, :infinity) 77 | end 78 | 79 | def send_pane_keys(session, pane_id, data) do 80 | GenServer.call(session, {:send_pane_keys, pane_id, data}, :infinity) 81 | end 82 | 83 | def send_exec_cmd(session, client_id, cmd) do 84 | GenServer.call(session, {:send_exec_cmd, client_id, cmd}, :infinity) 85 | end 86 | 87 | def notify_resize(session, ws, size) do 88 | GenServer.call(session, {:notify_resize, ws, size}, :infinity) 89 | end 90 | 91 | def notify_daemon_msg(session, msg) do 92 | GenServer.call(session, {:notify_daemon_msg, msg}, :infinity) 93 | end 94 | 95 | 96 | def handle_call({:ws_verify_auth}, _from, 97 | %{initialized: true, ssh_only: ssh_only}=state) do 98 | if ssh_only do 99 | {:reply, {:error, :auth}, state} 100 | else 101 | {:reply, :ok, state} 102 | end 103 | end 104 | 105 | def handle_call({:ws_request_sub, ws, client}, _from, state) do 106 | # We'll queue up the subscribers until we get the snapshot 107 | # so they can get a consistent stream. 108 | state = client_join(state, ws, client) 109 | Process.monitor(ws) 110 | send_daemon_msg(state, [P.tmate_ctl_request_snapshot, @max_snapshot_lines]) 111 | {:reply, :ok, %{state | pending_ws_subs: state.pending_ws_subs ++ [ws]}} 112 | end 113 | 114 | def handle_call({:send_pane_keys, pane_id, data}, _from, state) do 115 | send_daemon_msg(state, [P.tmate_ctl_pane_keys, pane_id, data]) 116 | {:reply, :ok, state} 117 | end 118 | 119 | def handle_call({:send_exec_cmd, client_id, cmd}, _from, state) do 120 | Logger.debug("Sending exec: #{cmd}") 121 | send_daemon_msg(state, [P.tmate_ctl_deamon_fwd_msg, 122 | [P.tmate_in_exec_cmd_str, client_id, cmd]]) 123 | {:reply, :ok, state} 124 | end 125 | 126 | def handle_call({:notify_resize, ws, size}, _from, state) do 127 | {:reply, :ok, update_client_size(state, ws, size)} 128 | end 129 | 130 | def handle_call({:notify_daemon_msg, msg}, _from, state) do 131 | {:reply, :ok, handle_ctl_msg(state, msg)} 132 | end 133 | 134 | defp emit_event(state, event_type, params \\ %{}) do 135 | Logger.debug("emit_event: #{event_type}") 136 | 137 | timestamp = DateTime.utc_now 138 | event = %Tmate.Webhook.Event{type: event_type, entity_id: state.id, 139 | timestamp: timestamp, generation: state.generation, params: params} 140 | 141 | Tmate.Webhook.Many.emit_event(state.webhooks, state.webhook_pids, event) 142 | end 143 | 144 | def pack_and_sign!(value) do 145 | {:ok, daemon_options} = Application.fetch_env(:tmate, :daemon) 146 | 147 | value 148 | |> MessagePack.pack! 149 | |> (& [&1, :crypto.hmac(:sha256, daemon_options[:hmac_key], &1)]).() 150 | |> Enum.map(&Base.encode64/1) 151 | |> Enum.join("|") 152 | end 153 | 154 | def verify_and_unpack!(value) do 155 | {:ok, daemon_options} = Application.fetch_env(:tmate, :daemon) 156 | 157 | value 158 | |> String.split("|") 159 | |> Enum.map(&Base.decode64!/1) 160 | |> fn [data, received_signature] -> 161 | ^received_signature = :crypto.hmac(:sha256, daemon_options[:hmac_key], data) 162 | data 163 | end.() 164 | |> MessagePack.unpack! 165 | end 166 | 167 | defp get_web_url_fmt() do 168 | user_facing_base_url = Application.get_env(:tmate, :master)[:user_facing_base_url] 169 | "#{user_facing_base_url}t/%s" 170 | end 171 | 172 | defp rename_tmux_sockets!(old_stoken, old_stoken_ro, stoken, stoken_ro) do 173 | {:ok, daemon_options} = Application.fetch_env(:tmate, :daemon) 174 | t = fn token -> String.replace(token, ["/", "."], "=") end 175 | p = fn token -> Path.join(daemon_options[:tmux_socket_path], token) end 176 | 177 | old_stoken = t.(old_stoken) 178 | old_stoken_ro = t.(old_stoken_ro) 179 | stoken = t.(stoken) 180 | stoken_ro = t.(stoken_ro) 181 | 182 | if old_stoken != stoken do 183 | :ok = File.rename(p.(old_stoken), p.(stoken)) 184 | end 185 | 186 | # The ro file is a symlink pointing to the rw socket, 187 | # so renaming is insufficient 188 | File.rm(p.(old_stoken_ro)) 189 | File.rm(p.(stoken_ro)) 190 | :ok = File.ln_s(stoken, p.(stoken_ro)) 191 | end 192 | 193 | @max_token_length 50 194 | @valid_token_regex ~r/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*$/ 195 | @api_key_length 30 196 | 197 | defp validate_session_token(token) do 198 | cond do 199 | !token -> :ok 200 | String.length(token) == 0 -> {:error, :empty_token} 201 | String.length(token) > @max_token_length -> {:error, :token_too_long} 202 | not String.match?(token, @valid_token_regex) -> {:error, :invalid_token} 203 | true -> :ok 204 | end 205 | end 206 | 207 | defp get_named_session_tokens(stoken, stoken_ro, 208 | %{api_key: api_key, rw: desired_stoken, ro: desired_stoken_ro}) do 209 | cond do 210 | !desired_stoken && !desired_stoken_ro -> 211 | {:ok, {stoken, stoken_ro}} 212 | (err = validate_session_token(desired_stoken)) != :ok -> 213 | err 214 | (err = validate_session_token(desired_stoken_ro)) != :ok -> 215 | err 216 | desired_stoken == desired_stoken_ro -> 217 | {:error, :same_tokens} 218 | !Tmate.MasterApi.enabled? -> 219 | {:ok, {desired_stoken || stoken, desired_stoken_ro || stoken_ro, 1}} 220 | !api_key -> 221 | {:error, :missing_api_key} 222 | String.length(api_key) != @api_key_length -> 223 | {:error, :invalid_api_key} 224 | true -> 225 | case Tmate.MasterApi.get_named_session_prefix(api_key) do 226 | {:ok, prefix} -> 227 | desired_stoken = desired_stoken && "#{prefix}#{desired_stoken}" 228 | desired_stoken_ro = desired_stoken_ro && "#{prefix}#{desired_stoken_ro}" 229 | {:ok, {desired_stoken || stoken, desired_stoken_ro || stoken_ro}} 230 | {:error, :not_found} -> 231 | {:error, :invalid_api_key} 232 | {:error, _reason} -> 233 | {:error, :internal_error} 234 | end 235 | end 236 | end 237 | 238 | defp notify_named_session_error(state, reason) do 239 | user_facing_base_url = Application.get_env(:tmate, :master)[:user_facing_base_url] 240 | reg_url = "#{user_facing_base_url}#api_key" 241 | case reason do 242 | :emoty_token -> 243 | notify_daemon(state, "The session name is empty") 244 | :token_too_long -> 245 | notify_daemon(state, "The session name length too long (max #{@max_token_length} chars)") 246 | :invalid_token -> 247 | notify_daemon(state, "The session name may only contain alphanumeric characters or single hyphens" 248 | <> ", and cannot begin or end with a hyphen") 249 | :same_tokens -> 250 | notify_daemon(state, "The same session name for write and read-only access were provided" 251 | <> ". Try again with different names") 252 | :missing_api_key -> 253 | notify_daemon(state, "To name sessions, specify your api key with -k" 254 | <> ". Get an api key at #{reg_url}") 255 | :invalid_api_key -> 256 | notify_daemon(state, "The provided api key is invalid. Please fix" 257 | <> ". You may reach out for help at support@tmate.io") 258 | :internal_error -> 259 | notify_daemon(state, "Temporary server error, tmate will reconnect") 260 | Process.exit(self(), {:shutdown, :master_api_fail}) 261 | end 262 | end 263 | 264 | defp finalize_session_init(%{init_state: %{ip_address: ip_address, stoken: stoken, 265 | stoken_ro: stoken_ro, ssh_cmd_fmt: ssh_cmd_fmt, named_session: named_session, 266 | client_version: client_version, reconnection_data: reconnection_data, uname: uname, 267 | user_webhook_opts: user_webhook_opts}, ssh_only: ssh_only, foreground: foreground}=state) do 268 | old_stoken = stoken 269 | old_stoken_ro = stoken_ro 270 | 271 | # named sessions 272 | {stoken, stoken_ro, named_session_error} = 273 | cond do 274 | reconnection_data -> {stoken, stoken_ro, nil} 275 | true -> 276 | case get_named_session_tokens(stoken, stoken_ro, named_session) do 277 | {:ok, {rw, ro}} -> {rw, ro, nil} 278 | {:error, reason} -> {stoken, stoken_ro, reason} 279 | end 280 | end 281 | 282 | named = stoken != old_stoken || stoken_ro != old_stoken_ro 283 | 284 | # reconnection 285 | {reconnected, [id, stoken, stoken_ro, _old_host, generation]} = case reconnection_data do 286 | nil -> {false, [UUID.uuid1, stoken, stoken_ro, nil, 1]} 287 | [2 | rdata_v2] -> {true, rdata_v2} 288 | rdata_v1 -> {true, rdata_v1 ++ [2]} 289 | end 290 | new_reconnection_data = [2, id, stoken, stoken_ro, Tmate.host, generation+1] 291 | 292 | # logging 293 | sformat = fn s -> cond do 294 | # Show named session fully (they contain a /), but truncate the rest 295 | String.match?(s, ~r/\//) -> s 296 | String.match?(s, ~r/^ro-/) -> "#{String.slice(s, 0, 7)}..." 297 | true -> "#{String.slice(s, 0, 4)}..." 298 | end end 299 | Logger.metadata(token: "[#{sformat.(stoken)}]", id: id) 300 | if reconnected do 301 | "Session reconnected (count=#{generation-1})" 302 | else 303 | "Session started stoken=#{sformat.(stoken)} stoken_ro=#{sformat.(stoken_ro)}" <> 304 | " ssh_only=#{inspect(ssh_only)}" <> 305 | " foreground=#{inspect(foreground)}" <> 306 | " named=#{inspect(named)}" 307 | end |> Logger.info 308 | 309 | # socket rename 310 | if old_stoken != stoken || old_stoken_ro != stoken_ro do 311 | rename_tmux_sockets!(old_stoken, old_stoken_ro, stoken, stoken_ro) 312 | send_daemon_msg(state, [P.tmate_ctl_rename_session, stoken, stoken_ro]) 313 | end 314 | 315 | # session registration 316 | state = Map.merge(state, %{id: id, generation: generation}) 317 | 318 | case state.registry do 319 | {} -> nil 320 | {registry_mod, registry_pid} -> 321 | :ok = registry_mod.register_session(registry_pid, 322 | self(), state.id, stoken, stoken_ro) 323 | end 324 | 325 | # webhook setup 326 | state = if user_webhook_opts[:url] do 327 | Logger.info("User webhook: #{inspect(user_webhook_opts)}") 328 | %{state | webhooks: state.webhooks ++ [{Tmate.Webhook, user_webhook_opts}]} 329 | else 330 | state 331 | end 332 | 333 | state = %{state | webhook_pids: Tmate.Webhook.Many.start_links(state.webhooks)} 334 | 335 | web_url_fmt = get_web_url_fmt() 336 | 337 | event_payload = %{ip_address: ip_address, client_version: client_version, 338 | stoken: stoken, stoken_ro: stoken_ro, reconnected: reconnected, 339 | ssh_only: ssh_only, foreground: foreground, named: named, uname: uname, 340 | ssh_cmd_fmt: ssh_cmd_fmt, ws_url_fmt: WebSocket.ws_url_fmt, 341 | web_url_fmt: web_url_fmt} 342 | 343 | emit_event(state, :session_register, event_payload) 344 | 345 | # notifications 346 | ssh_cmd = String.replace(ssh_cmd_fmt, "%s", stoken) 347 | ssh_cmd_ro = String.replace(ssh_cmd_fmt, "%s", stoken_ro) 348 | 349 | web_url = String.replace(web_url_fmt, "%s", stoken) 350 | web_url_ro = String.replace(web_url_fmt, "%s", stoken_ro) 351 | 352 | if !reconnected do 353 | if !foreground, do: notify_daemon(state, "Note: clear your terminal before sharing readonly access") 354 | if !ssh_only, do: notify_daemon(state, "web session read only: #{web_url_ro}") 355 | notify_daemon(state, "ssh session read only: #{ssh_cmd_ro}") 356 | if !ssh_only, do: notify_daemon(state, "web session: #{web_url}") 357 | notify_daemon(state, "ssh session: #{ssh_cmd}") 358 | else 359 | notify_daemon(state, "Reconnected") 360 | end 361 | 362 | if named_session_error, do: notify_named_session_error(state, named_session_error) 363 | 364 | if !ssh_only, do: daemon_set_env(state, "tmate_web_ro", web_url_ro) 365 | daemon_set_env(state, "tmate_ssh_ro", ssh_cmd_ro) 366 | if !ssh_only, do: daemon_set_env(state, "tmate_web", web_url) 367 | daemon_set_env(state, "tmate_ssh", ssh_cmd) 368 | set_env_num_clients(state, 0) 369 | 370 | daemon_set_env(state, "tmate_reconnection_data", pack_and_sign!(new_reconnection_data)) 371 | 372 | daemon_send_client_ready(state) 373 | 374 | maybe_notice_version_upgrade(client_version) 375 | 376 | %{state | initialized: true, init_state: nil} 377 | end 378 | 379 | defp maybe_notice_version_upgrade(@latest_version), do: nil 380 | defp maybe_notice_version_upgrade(_client_version) do 381 | delayed_notify_daemon(20 * 1000, "tmate can be upgraded to #{@latest_version}. #{@upgrade_msg}") 382 | end 383 | 384 | defp handle_ctl_msg(%{initialized: false}=state, 385 | [P.tmate_ctl_header, 2=_protocol_version, ip_address, _pubkey, 386 | stoken, stoken_ro, ssh_cmd_fmt, client_version, daemon_protocol_version]) do 387 | init_state = %{ip_address: ip_address, stoken: stoken, stoken_ro: stoken_ro, 388 | client_version: client_version, ssh_cmd_fmt: ssh_cmd_fmt, 389 | named_session: %{rw: nil, ro: nil, api_key: nil}, uname: nil, 390 | reconnection_data: nil, user_webhook_opts: [url: nil, userdata: ""]} 391 | state = %{state | daemon_protocol_version: daemon_protocol_version, init_state: init_state} 392 | 393 | if daemon_protocol_version >= 6 do 394 | # we'll finalize when we get the ready message 395 | state 396 | else 397 | finalize_session_init(state) 398 | end 399 | end 400 | 401 | defp handle_ctl_msg(state, [P.tmate_ctl_deamon_out_msg, dmsg]) do 402 | ws_broadcast_msg(state.ws_subs, [P.tmate_ws_daemon_out_msg, dmsg]) 403 | handle_daemon_msg(state, dmsg) 404 | end 405 | 406 | defp handle_ctl_msg(state, [P.tmate_ctl_snapshot, smsg]) do 407 | layout_msg = [P.tmate_ws_daemon_out_msg, [P.tmate_out_sync_layout | state.current_layout]] 408 | snapshot_msg = [P.tmate_ws_snapshot, smsg] 409 | 410 | ws_broadcast_msg(state.pending_ws_subs, layout_msg) 411 | ws_broadcast_msg(state.pending_ws_subs, snapshot_msg) 412 | 413 | %{state | pending_ws_subs: [], ws_subs: state.ws_subs ++ state.pending_ws_subs} 414 | end 415 | 416 | defp handle_ctl_msg(state, [P.tmate_ctl_client_join, client_id, ip_address, _pubkey, readonly]) do 417 | client_join(state, client_id, %{type: :ssh, ip_address: ip_address, readonly: readonly}) 418 | end 419 | 420 | defp handle_ctl_msg(state, [P.tmate_ctl_client_left, client_id]) do 421 | client_left(state, client_id) 422 | end 423 | 424 | defp handle_ctl_msg(state, [P.tmate_ctl_latency, _client_id, _latency]) do 425 | state 426 | end 427 | 428 | defp handle_ctl_msg(state, [P.tmate_ctl_exec, username, ip_address, _pubkey, command]) do 429 | Logger.info("ssh exec: #{inspect(command)} from #{username}@#{ip_address}") 430 | command = String.split(command, " ") |> Enum.filter(& &1 != "") 431 | ssh_exec(state, command, username, ip_address) 432 | state 433 | end 434 | 435 | defp handle_ctl_msg(state, msg) do 436 | Logger.error("Unknown message type=#{inspect(msg)}") 437 | state 438 | end 439 | 440 | defp handle_daemon_msg(state, [P.tmate_out_sync_layout | layout]) do 441 | %{state | current_layout: layout} 442 | end 443 | 444 | defp handle_daemon_msg(state, [P.tmate_out_uname, sysname, nodename, 445 | release, version, machine]) do 446 | uname = %{sysname: sysname, nodename: nodename, 447 | release: release, version: version, machine: machine} 448 | 449 | %{state | init_state: %{state.init_state | uname: uname}} 450 | end 451 | 452 | defp handle_daemon_msg(state, [P.tmate_out_ready]) do 453 | finalize_session_init(state) 454 | end 455 | 456 | defp handle_daemon_msg(state, [P.tmate_out_reconnect, reconnection_data]) do 457 | reconnection_data = verify_and_unpack!(reconnection_data) 458 | %{state | init_state: %{state.init_state | reconnection_data: reconnection_data}} 459 | end 460 | 461 | defp handle_daemon_msg(state, [P.tmate_out_fin]) do 462 | Process.exit(self(), {:shutdown, :session_fin}) 463 | state 464 | end 465 | 466 | defp handle_daemon_msg(state, [P.tmate_out_exec_cmd | args]) do 467 | handle_daemon_exec_cmd(state, args) 468 | end 469 | 470 | defp handle_daemon_msg(state, _msg) do 471 | state 472 | end 473 | 474 | defp set_webhook_setting(state, key, value) do 475 | # Due to a bug in tmate client 2.3.0 and lower, we are seing the tmate 476 | # webhook options after the session is ready (and thus initialization 477 | # complete). This bug was fixed with commit d654ff22 in tmate client. 478 | # As a workaround, we kill the connection. When the client reconnects, 479 | # it provides the webhook configurations before the ready event. 480 | case state.init_state do 481 | nil -> 482 | Logger.debug("Webhook bug workaround: disconnecting client") 483 | Process.exit(self(), {:shutdown, :bug_webhook}) 484 | state 485 | init -> 486 | user_webhook_opts = Keyword.put(init.user_webhook_opts, key, value) 487 | %{state | init_state: %{init | user_webhook_opts: user_webhook_opts}} 488 | end 489 | end 490 | 491 | defp set_named_session_setting(state, key, value) do 492 | if state.init_state do 493 | named_session = state.init_state.named_session 494 | named_session = Map.replace!(named_session, key, value) 495 | %{state | init_state: %{state.init_state | named_session: named_session}} 496 | else 497 | notify_daemon(state, "#{key} can only be set via the command line, or configuration file") 498 | state 499 | end 500 | end 501 | 502 | defp handle_daemon_exec_cmd(state, ["set-option", "-g" | rest]) do 503 | handle_daemon_exec_cmd(state, ["set-option"] ++ rest) 504 | end 505 | 506 | defp handle_daemon_exec_cmd(state, ["set-option", _key, ""]), do: state # important to filter empty session names 507 | defp handle_daemon_exec_cmd(state, ["set-option", key, value]) when is_binary(value) do 508 | case key do 509 | "tmate-webhook-url" -> set_webhook_setting(state, :url, value) 510 | "tmate-webhook-userdata" -> set_webhook_setting(state, :userdata, value) 511 | "tmate-session-name" -> set_named_session_setting(state, :rw, value) 512 | "tmate-session-name-ro" -> set_named_session_setting(state, :ro, value) 513 | "tmate-api-key" -> set_named_session_setting(state, :api_key, value) 514 | "tmate-authorized-keys" -> %{state | ssh_only: true} 515 | "tmate-set" -> case value do 516 | "authorized_keys=" <> _ssh_key -> %{state | ssh_only: true} 517 | "foreground=true" -> %{state | foreground: true} 518 | _ -> state 519 | end 520 | _ -> state 521 | end 522 | end 523 | 524 | defp handle_daemon_exec_cmd(state, _args) do 525 | state 526 | end 527 | 528 | defp handle_ws_disconnect(state, ws) do 529 | state = client_left(state, ws) 530 | recalculate_sizes(state) 531 | %{state | pending_ws_subs: state.pending_ws_subs -- [ws], 532 | ws_subs: state.ws_subs -- [ws]} 533 | end 534 | 535 | defp ws_broadcast_msg(ws_list, msg) do 536 | # TODO we'll need a better buffering strategy 537 | # Right now we are sending async messages, with no back pressure. 538 | # This might be problematic. 539 | # TODO We might want to serialize the msg here to avoid doing it N times. 540 | for ws <- ws_list, do: WebSocket.send_msg(ws, msg) 541 | end 542 | 543 | defp send_daemon_msg(state, msg) do 544 | {transport, handle} = state.daemon 545 | transport.send_msg(handle, msg) 546 | end 547 | 548 | defp delayed_notify_daemon(timeout, msg) do 549 | :erlang.start_timer(timeout, self(), {:notify_daemon, msg}) 550 | end 551 | 552 | defp notify_daemon(state, msg) do 553 | send_daemon_msg(state, [P.tmate_ctl_deamon_fwd_msg, 554 | [P.tmate_in_notify, msg]]) 555 | end 556 | 557 | defp daemon_set_env(%{daemon_protocol_version: v}, _, _) when v < 4, do: nil 558 | defp daemon_set_env(state, key, value) do 559 | send_daemon_msg(state, [P.tmate_ctl_deamon_fwd_msg, 560 | [P.tmate_in_set_env, key, value]]) 561 | end 562 | 563 | defp daemon_send_client_ready(%{daemon_protocol_version: v}) when v < 4, do: nil 564 | defp daemon_send_client_ready(state) do 565 | send_daemon_msg(state, [P.tmate_ctl_deamon_fwd_msg, 566 | [P.tmate_in_ready]]) 567 | end 568 | 569 | defp notify_exec_response(state, exit_code, msg) do 570 | msg = ((msg |> String.split("\n")) ++ [""]) |> Enum.join("\r\n") 571 | send_daemon_msg(state, [P.tmate_ctl_exec_response, exit_code, msg]) 572 | end 573 | 574 | defp client_join(state, ref, client) do 575 | client_id = UUID.uuid1 576 | client = Map.merge(client, %{id: client_id}) 577 | 578 | state = %{state | clients: Map.put(state.clients, ref, client)} 579 | update_client_presence(state, client, true) 580 | state 581 | end 582 | 583 | defp client_left(state, ref) do 584 | case Map.fetch(state.clients, ref) do 585 | {:ok, client} -> 586 | state = %{state | clients: Map.delete(state.clients, ref)} 587 | update_client_presence(state, client, false) 588 | state 589 | :error -> 590 | Logger.error("Missing client #{inspect(ref)} in client list") 591 | state 592 | end 593 | end 594 | 595 | defp update_client_presence(state, client, join) do 596 | notify_client_presence_daemon(state, client, join) 597 | notify_client_presence_webhooks(state, client, join) 598 | end 599 | 600 | defp notify_client_presence_webhooks(state, client, true) do 601 | {client_info, _} = Map.split(client, [:id, :type, :ip_address, :identity, :readonly]) 602 | emit_event(state, :session_join, client_info) 603 | end 604 | 605 | defp notify_client_presence_webhooks(state, client, false) do 606 | emit_event(state, :session_left, %{id: client.id}) 607 | end 608 | 609 | defp notify_client_presence_daemon(state, client, join) do 610 | verb = if join, do: 'joined', else: 'left' 611 | num_clients = Kernel.map_size(state.clients) 612 | msg = "A mate has #{verb} (#{client.ip_address}) -- " <> 613 | "#{num_clients} client#{if num_clients > 1, do: 's'} currently connected" 614 | set_env_num_clients(state, num_clients) 615 | notify_daemon(state, msg) 616 | end 617 | 618 | defp set_env_num_clients(state, num_clients) do 619 | daemon_set_env(state, "tmate_num_clients", "#{num_clients}") 620 | end 621 | 622 | defp update_client_size(state, ref, size) do 623 | client = Map.fetch!(state.clients, ref) 624 | client = Map.merge(client, %{size: size}) 625 | state = %{state | clients: Map.put(state.clients, ref, client)} 626 | recalculate_sizes(state) 627 | state 628 | end 629 | 630 | def recalculate_sizes(state) do 631 | sizes = state.clients 632 | |> Map.values 633 | |> Enum.filter(& &1[:size]) 634 | |> Enum.map(& &1[:size]) 635 | 636 | {max_cols, max_rows} = if Enum.empty?(sizes) do 637 | {-1,-1} 638 | else 639 | sizes |> Enum.reduce(fn({x,y}, {xx,yy}) -> {Enum.min([x,xx]), Enum.min([y,yy])} end) 640 | end 641 | 642 | send_daemon_msg(state, [P.tmate_ctl_resize, max_cols, max_rows]) 643 | end 644 | 645 | ##### SSH EXEC ##### 646 | 647 | defp human_time(time) do 648 | {:ok, rel} = Timex.format(time, "{relative}", :relative) 649 | rel 650 | end 651 | 652 | defp describe_session(%{disconnected_at: nil, ssh_cmd_fmt: ssh_cmd_fmt}, token) do 653 | web_url_fmt = get_web_url_fmt() 654 | 655 | ssh_conn = String.replace(ssh_cmd_fmt, "%s", token) 656 | web_conn = String.replace(web_url_fmt, "%s", token) 657 | 658 | "The session has moved to another server. Use the following to connect:\n" <> 659 | "web session: #{web_conn}\n" <> 660 | "ssh session: #{ssh_conn}" 661 | end 662 | 663 | defp describe_session(%{closed: true, disconnected_at: time}, _token) do 664 | "This session was closed #{human_time(time)}." 665 | end 666 | 667 | defp describe_session(%{closed: false, disconnected_at: time}, _token) do 668 | "The session host disconnected #{human_time(time)}.\n" <> 669 | "Hopefully it will reconnect soon. You may try again later." 670 | end 671 | 672 | defp ssh_exec(state, ["explain-session-not-found"], username, _ip_address) do 673 | token = username 674 | 675 | response = cond do 676 | Tmate.MasterApi.enabled? -> Tmate.MasterApi.get_session(token) 677 | true -> {:error, :not_found} 678 | end |> case do 679 | {:ok, session} -> 680 | describe_session(session, token) 681 | {:error, :not_found} -> 682 | :timer.sleep(:crypto.rand_uniform(50, 200)) 683 | "Invalid session token" 684 | {:error, _reason} -> 685 | :timer.sleep(:crypto.rand_uniform(50, 200)) 686 | "Internal error" 687 | end 688 | notify_exec_response(state, 1, response) 689 | end 690 | 691 | defp ssh_exec(state, _command, _username, _ip_address) do 692 | notify_exec_response(state, 1, "Invalid command") 693 | end 694 | end 695 | -------------------------------------------------------------------------------- /lib/tmate/session_registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.SessionRegistry do 2 | use GenServer 3 | require Logger 4 | 5 | require Record 6 | Record.defrecord :session, [:stoken, :stoken_ro, :id, :pid, :monitor] 7 | 8 | def start_link(opts \\ []) do 9 | GenServer.start_link(__MODULE__, :ok, opts) 10 | end 11 | 12 | def init(:ok) do 13 | {:ok, %{sessions: []}} 14 | end 15 | 16 | def register_session(registry, pid, id, stoken, stoken_ro) do 17 | GenServer.call(registry, {:register_session, pid, id, stoken, stoken_ro}, :infinity) 18 | end 19 | 20 | def get_session(registry, token) do 21 | GenServer.call(registry, {:get_session, token}, :infinity) 22 | end 23 | 24 | def get_session_by_id(registry, id) do 25 | GenServer.call(registry, {:get_session_by_id, id}, :infinity) 26 | end 27 | 28 | defmacrop lookup_session(state, what, token) do 29 | quote do: List.keyfind(unquote(state).sessions, unquote(token), session(unquote(what))) 30 | end 31 | 32 | def handle_call({:register_session, pid, id, stoken, stoken_ro}, _from, state) do 33 | {:reply, :ok, add_session(state, pid, id, stoken, stoken_ro)} 34 | end 35 | 36 | def handle_call({:get_session, token}, _from, state) do 37 | cond do 38 | session = lookup_session(state, :stoken, token) -> 39 | {:reply, {:rw, session(session, :pid)}, state} 40 | session = lookup_session(state, :stoken_ro, token) -> 41 | {:reply, {:ro, session(session, :pid)}, state} 42 | true -> {:reply, :error, state} 43 | end 44 | end 45 | 46 | def handle_call({:get_session_by_id, id}, _from, state) do 47 | cond do 48 | session = lookup_session(state, :id, id) -> 49 | {:reply, session(session, :pid), state} 50 | true -> {:reply, :error, state} 51 | end 52 | end 53 | 54 | defp add_session(state, pid, id, stoken, stoken_ro) do 55 | if s = lookup_session(state, :id, id ) || 56 | lookup_session(state, :stoken, stoken ) || 57 | lookup_session(state, :stoken, stoken_ro) || 58 | lookup_session(state, :stoken_ro, stoken_ro) || 59 | lookup_session(state, :stoken_ro, stoken ) do 60 | Logger.info("Replacing stale session #{id}") 61 | state = kill_session(state, s, {:shutdown, :stale}) 62 | add_session(state, pid, id, stoken, stoken_ro) 63 | else 64 | monitor = Process.monitor(pid) 65 | new_session = session(stoken: stoken, stoken_ro: stoken_ro, 66 | id: id, pid: pid, monitor: monitor) 67 | %{state | sessions: [new_session | state.sessions]} 68 | end 69 | end 70 | 71 | def handle_info({:DOWN, _ref, _type, pid, _info}, state) do 72 | {:noreply, cleanup_session(state, pid)} 73 | end 74 | 75 | defp cleanup_session(state, pid) do 76 | %{state | sessions: List.keydelete(state.sessions, pid, session(:pid))} 77 | end 78 | 79 | defp kill_session(state, session, reason) do 80 | Process.demonitor(session(session, :monitor), [:flush]) 81 | Process.exit(session(session, :pid), reason) 82 | cleanup_session(state, session(session, :pid)) 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/tmate/util/json_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Util.JsonApi do 2 | defmacro __using__(opts) do 3 | quote do 4 | import Tmate.Util.JsonApi 5 | use HTTPoison.Base 6 | alias HTTPoison.Request 7 | alias HTTPoison.Response 8 | alias HTTPoison.Error 9 | require Logger 10 | 11 | @opts unquote(opts[:fn_opts]) 12 | 13 | defp opts() do 14 | if is_function(@opts), do: @opts.(), else: @opts 15 | end 16 | 17 | def process_url(url) do 18 | base_url = opts()[:base_url] 19 | if base_url, do: base_url <> url, else: url 20 | end 21 | 22 | def process_request_headers(headers) do 23 | auth_token = opts()[:auth_token] 24 | auth_headers = if auth_token, do: [{"Authorization", "Bearer " <> auth_token}], else: [] 25 | json_headers = [{"Content-Type", "application/json"}, {"Accept", "application/json"}] 26 | headers ++ auth_headers ++ json_headers 27 | end 28 | 29 | def process_request_body(""), do: "" 30 | def process_request_body(body) do 31 | Jason.encode!(body) 32 | end 33 | 34 | def process_response(%Response{headers: headers, body: body} = response) do 35 | content_type_hdr = Enum.find(headers, fn {name, _} -> name == "content-type" end) 36 | body = case content_type_hdr do 37 | {_, "application/json" <> _} -> Jason.decode!(body) 38 | _ -> body 39 | end 40 | 41 | %{response | body: body} 42 | end 43 | 44 | defp simplify_response({:ok, %Response{status_code: 200, body: body}}, _) do 45 | {:ok, body} 46 | end 47 | 48 | defp simplify_response({:ok, %Response{status_code: status_code}}, 49 | %Request{url: url, method: method}) do 50 | Logger.error("API error: #{method} #{url} [#{status_code}]") 51 | {:error, status_code} 52 | end 53 | 54 | defp simplify_response({:error, %Error{reason: reason}}, 55 | %Request{url: url, method: method}) do 56 | Logger.error("API error: #{method} #{url} [#{reason}]") 57 | {:error, reason} 58 | end 59 | 60 | defp debug_response({:ok, %Response{status_code: status_code, body: resp_body}} = response, 61 | %Request{url: url, body: req_body, params: params, method: method}) do 62 | Logger.debug("API Request: #{inspect(method)} #{inspect(url)} #{inspect(params)} #{inspect(req_body)}") 63 | Logger.debug("API Response: #{inspect(resp_body)} #{inspect(status_code)}") 64 | response 65 | end 66 | defp debug_response(resp, _req), do: resp 67 | 68 | def request(request) do 69 | super(request) 70 | |> debug_response(request) 71 | |> simplify_response(request) 72 | end 73 | 74 | def request!(method, url, body \\ "", headers \\ [], options \\ []) do 75 | case request(method, url, body, headers, options) do 76 | {:ok, body} -> body 77 | {:error, reason} -> raise Error, reason: reason 78 | end 79 | end 80 | end 81 | end 82 | 83 | def with_atom_keys(obj) do 84 | Map.new(obj, fn {k, v} -> 85 | v = if is_map(v), do: with_atom_keys(v), else: v 86 | {String.to_atom(k), v} 87 | end) 88 | end 89 | 90 | def as_atom(obj, key) do 91 | value = Map.get(obj, key) 92 | value = if value, do: String.to_atom(value), else: value 93 | Map.put(obj, key, value) 94 | end 95 | 96 | def as_timestamp(obj, key) do 97 | value = Map.get(obj, key) 98 | 99 | value = if value do 100 | {:ok, timestamp, 0} = DateTime.from_iso8601(value) 101 | timestamp 102 | else 103 | value 104 | end 105 | 106 | Map.put(obj, key, value) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/tmate/util/plug_verify_auth_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Util.PlugVerifyAuthToken do 2 | @behaviour Plug 3 | 4 | defmodule Error.Unauthorized do 5 | defexception message: "Unauthorized", plug_status: 401 6 | end 7 | 8 | def init(opts) do 9 | opts 10 | end 11 | 12 | def call(conn, opts) do 13 | opts = if opts[:fn_opts], do: opts[:fn_opts].(), else: opts 14 | verify_auth_token!(conn, opts) 15 | conn 16 | end 17 | 18 | defp verify_auth_token(%{req_headers: req_headers}, opts) do 19 | auth_header = Enum.find(req_headers, fn {name, _} -> name == "authorization" end) 20 | case auth_header do 21 | {_, "Bearer " <> token} -> Plug.Crypto.secure_compare(token, opts[:auth_token]) 22 | _ -> false 23 | end 24 | end 25 | 26 | defp verify_auth_token!(conn, opts) do 27 | if (!verify_auth_token(conn, opts)), do: raise Error.Unauthorized 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/tmate/webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Webhook do 2 | use GenServer 3 | require Logger 4 | 5 | defmodule Event do 6 | @enforce_keys [:type, :entity_id, :timestamp, :generation] 7 | @derive Jason.Encoder 8 | defstruct @enforce_keys ++ [:userdata, params: %{}] 9 | end 10 | 11 | # We use a genserver per session because we don't want to block the session 12 | # process, but we still want to keep the events ordered. 13 | def start_link(webhook, opts \\ []) do 14 | GenServer.start_link(__MODULE__, webhook, opts) 15 | end 16 | 17 | def init(webhook) do 18 | {:ok, webhook_options} = Application.fetch_env(:tmate, :webhook) 19 | max_attempts = webhook_options[:max_attempts] 20 | initial_retry_interval = webhook_options[:initial_retry_interval] 21 | 22 | state = %{url: webhook[:url], userdata: webhook[:userdata], 23 | max_attempts: max_attempts, initial_retry_interval: initial_retry_interval} 24 | Process.flag(:trap_exit, true) 25 | {:ok, state} 26 | end 27 | 28 | def emit_event(pid, event) do 29 | GenServer.cast(pid, {:emit_event, event}) 30 | end 31 | 32 | def handle_cast({:emit_event, event}, state) do 33 | do_emit_event(event, state) 34 | {:noreply, state} 35 | end 36 | 37 | defp do_emit_event(event, state) do 38 | event = %{event | userdata: state.userdata} 39 | payload = Jason.encode!(event) 40 | post_event(state, event.type, payload, 1) 41 | end 42 | 43 | defp post_event(state, event_type, payload, num_attempts) do 44 | url = state.url 45 | case post_event_once(url, payload) do 46 | :ok -> :ok 47 | {:error, reason} -> 48 | if num_attempts == state.max_attempts do 49 | Logger.error "Webhook fail on #{url} - Dropping event :#{event_type} (#{inspect(reason)})" 50 | :error 51 | else 52 | if num_attempts == 1 do 53 | Logger.warn "Webhook fail on #{url} - Retrying event :#{event_type} (#{inspect(reason)})" 54 | end 55 | 56 | :timer.sleep(state.initial_retry_interval * Kernel.trunc(:math.pow(2, num_attempts-1))) 57 | post_event(state, event_type, payload, num_attempts + 1) 58 | end 59 | end 60 | end 61 | 62 | defp post_event_once(url, payload) do 63 | headers = [{"Content-Type", "application/json"}, {"Accept", "application/json"}] 64 | # We need force_redirect: true, otherwise, post data doesn't get reposted. 65 | case HTTPoison.post(url, payload, headers, hackney: [pool: :default, force_redirect: true], follow_redirect: true) do 66 | {:ok, %HTTPoison.Response{status_code: status_code}} when status_code >= 200 and status_code < 300 -> 67 | :ok 68 | {:ok, %HTTPoison.Response{status_code: status_code}} -> 69 | {:error, "status=#{status_code}"} 70 | {:error, %HTTPoison.Error{reason: reason}} -> 71 | {:error, reason} 72 | end 73 | end 74 | 75 | defmodule Many do 76 | def start_links(webhooks) do 77 | Enum.map(webhooks, fn {webhook_mod, webhook_opts} -> 78 | {:ok, pid} = webhook_mod.start_link(webhook_opts) 79 | pid 80 | end) 81 | end 82 | 83 | def emit_event(webhooks, pids, event) do 84 | Enum.zip(webhooks, pids) 85 | |> Enum.each(fn {{webhook_mod, _webhook_opts}, pid} -> 86 | webhook_mod.emit_event(pid, event) 87 | end) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/tmate/ws_api/internal_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WsApi.InternalApi do 2 | require Logger 3 | import Plug.Conn 4 | 5 | use Plug.Router 6 | plug :match 7 | 8 | plug Plug.Parsers, parsers: [:json], json_decoder: Jason 9 | 10 | def internal_api_opts do 11 | # XXX We can't pass the auth token directly, it is not 12 | # necessarily defined at compile time. 13 | Application.fetch_env!(:tmate, :master)[:internal_api] 14 | end 15 | plug Tmate.Util.PlugVerifyAuthToken, fn_opts: &__MODULE__.internal_api_opts/0 16 | 17 | plug :dispatch, builder_opts() 18 | 19 | post "get_stale_sessions" do 20 | stale_ids = get_stale_sessions_stub(conn.body_params, opts) 21 | json(conn, 200, %{stale_ids: stale_ids}) 22 | end 23 | 24 | defp get_stale_sessions_stub(%{"session_ids" => session_ids}, opts) 25 | when is_list(session_ids) do 26 | {registry_mod, registry_pid} = opts[:registry] 27 | Enum.flat_map(session_ids, fn id -> 28 | case registry_mod.get_session_by_id(registry_pid, id) do 29 | :error -> [id] 30 | _ -> [] 31 | end 32 | end) 33 | end 34 | 35 | defp json(conn, status, body) do 36 | conn 37 | |> put_resp_header("content-type", "application/json") 38 | |> send_resp(status, Jason.encode!(body)) 39 | end 40 | 41 | match _ do 42 | send_resp(conn, 404, ":(") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tmate/ws_api/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WsApi.Router do 2 | use Plug.Router 3 | use Plug.ErrorHandler 4 | 5 | def cowboy_dispatch(session_opts) do 6 | :cowboy_router.compile([{:_, [ 7 | {"/ws/session/[...]", Tmate.WsApi.WebSocket, []}, 8 | {:_, Plug.Cowboy.Handler, {__MODULE__, session_opts}}, 9 | ]}]) 10 | end 11 | 12 | plug :match 13 | plug Plug.Logger, log: :debug 14 | plug :dispatch, builder_opts() 15 | 16 | match "/internal_api/*glob" do 17 | Plug.Router.Utils.forward(conn, ["internal_api"], Tmate.WsApi.InternalApi, opts) 18 | end 19 | 20 | get "/" do 21 | {:ok, master_options} = Application.fetch_env(:tmate, :master) 22 | url = master_options[:user_facing_base_url] 23 | html = Plug.HTML.html_escape(url) 24 | body = "
You are being redirected." 25 | 26 | conn 27 | |> put_resp_header("location", url) 28 | |> send_resp(302, body) 29 | end 30 | 31 | match _ do 32 | send_resp(conn, 404, ":(") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/tmate/ws_api/websocket.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WsApi.WebSocket do 2 | require Logger 3 | require Tmate.ProtocolDefs, as: P 4 | 5 | alias :cowboy_req, as: Request 6 | 7 | @ping_interval_sec 30 # cowboy disconnects if activity stops after 60s 8 | 9 | def init(req, _opts) do 10 | token_path = Request.path_info(req) 11 | stoken = Enum.join(token_path, "/") # tokens can have /, and comes in as an array 12 | 13 | Logger.metadata([stoken: stoken]) 14 | 15 | state = %{} 16 | 17 | # TODO Check the request origin 18 | 19 | # TODO monads? 20 | case identity = get_identity(req) do 21 | nil -> {:ok, Request.reply(401, %{}, "Cannot get identity", req), state} 22 | _ -> 23 | case Tmate.SessionRegistry.get_session(Tmate.SessionRegistry, stoken) do 24 | {mode, session} -> 25 | case Tmate.Session.ws_verify_auth(session) do 26 | :ok -> 27 | ip = case req do 28 | %{proxy_header: %{src_address: ip}} -> ip 29 | %{peer: {ip, _port}} -> ip 30 | end 31 | ip = :inet_parse.ntoa(ip) |> to_string 32 | state = %{session: session, access_mode: mode, identity: identity, ip: ip} 33 | {:cowboy_websocket, req, state, %{compress: true}} 34 | {:error, :auth}-> 35 | {:ok, Request.reply(403, %{}, "SSH access required", req), state} 36 | end 37 | :error -> 38 | :timer.sleep(:crypto.rand_uniform(50, 200)) 39 | {:ok, Request.reply(404, %{}, "Session not found", req), state} 40 | end 41 | end 42 | end 43 | 44 | defp get_identity(_req) do 45 | UUID.uuid1() 46 | end 47 | 48 | # defp get_identity(req) do 49 | # {:ok, websocket_options} = Application.fetch_env(:tmate, :websocket) 50 | # opts = websocket_options[:cookie_opts] 51 | 52 | # store = Plug.Session.COOKIE 53 | # store_opts = store.init(opts) 54 | # store_opts = %{store_opts | key_opts: Keyword.put(store_opts.key_opts, :cache, nil)} 55 | # conn = %{secret_key_base: opts[:secret_key_base]} 56 | 57 | # {cookie, _} = Request.cookie(opts[:key], req) 58 | # case cookie do 59 | # :undefined -> nil 60 | # _ -> 61 | # {:term, %{"identity" => identity}} = store.get(conn, cookie, store_opts) 62 | # identity 63 | # end 64 | # end 65 | 66 | def ws_url_fmt do 67 | {:ok, ws_env} = Application.fetch_env(:tmate, :websocket) 68 | if ws_env[:enabled] == false do 69 | "disabled" 70 | else 71 | "#{ws_env[:base_url]}ws/session/%s" 72 | end 73 | end 74 | 75 | def send_msg(ws, msg) do 76 | send(ws, {:send_msg, msg}) 77 | end 78 | 79 | def websocket_init(state) do 80 | Logger.info("Accepted websocket connection (ip=#{state.ip}) (access_mode=#{state.access_mode})") 81 | 82 | Process.monitor(state.session) 83 | 84 | client_info = %{type: :web, identity: state.identity, ip_address: state.ip, 85 | readonly: [ro: true, rw: false][state.access_mode]} 86 | :ok = Tmate.Session.ws_request_sub(state.session, self(), client_info) 87 | 88 | start_ping_timer() 89 | {:ok, state} 90 | end 91 | 92 | def websocket_handle({:binary, msg}, %{access_mode: :rw} = state) do 93 | handle_ws_msg(state, deserialize_msg!(msg)) 94 | {:ok, state} 95 | end 96 | 97 | def websocket_handle({:binary, _msg}, state) do 98 | {:ok, state} 99 | end 100 | 101 | def websocket_handle({:pong, _msg}, state) do 102 | {:ok, state} 103 | end 104 | 105 | def websocket_handle(_, state) do 106 | {:ok, state} 107 | end 108 | 109 | defp start_ping_timer(timeout \\ @ping_interval_sec * 1000) do 110 | :erlang.start_timer(timeout, self(), :ping) 111 | end 112 | 113 | def websocket_info({:timeout, _ref, :ping}, state) do 114 | start_ping_timer() 115 | {:reply, {:ping, "P"}, state} 116 | end 117 | 118 | def websocket_info({:DOWN, _ref, _type, _pid, _info}, state) do 119 | {:reply, :close, state} 120 | end 121 | 122 | def websocket_info({:send_msg, msg}, state) do 123 | {:reply, serialize_msg!(msg), state} 124 | end 125 | 126 | # def websocket_terminate(_reason, _req, _state) do 127 | # :ok 128 | # end 129 | 130 | # def terminate(_reason, _req, _state) do 131 | # :ok 132 | # end 133 | 134 | # TODO validate types 135 | defp handle_ws_msg(state, [P.tmate_ws_pane_keys, pane_id, data]) 136 | when is_integer(pane_id) and pane_id >= 0 and is_binary(data) do 137 | :ok = Tmate.Session.send_pane_keys(state.session, pane_id, data) 138 | end 139 | 140 | defp handle_ws_msg(state, [P.tmate_ws_exec_cmd, cmd]) when is_binary(cmd) do 141 | :ok = Tmate.Session.send_exec_cmd(state.session, 0, cmd) 142 | end 143 | 144 | defp handle_ws_msg(state, [P.tmate_ws_resize, [max_cols, max_rows]]) 145 | when is_integer(max_cols) and max_cols >= 0 and 146 | is_integer(max_rows) and max_rows >= 0 do 147 | :ok = Tmate.Session.notify_resize(state.session, self(), {max_cols, max_rows}) 148 | end 149 | 150 | defp handle_ws_msg(_state, msg) do 151 | Logger.warn("Unknown ws msg: #{inspect(msg)}") 152 | end 153 | 154 | defp serialize_msg!(msg) do 155 | {:binary, MessagePack.pack!(msg, enable_string: true)} 156 | end 157 | 158 | defp deserialize_msg!(msg) do 159 | MessagePack.unpack!(msg) 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :tmate, 6 | version: "0.1.1", 7 | elixir: "~> 1.9", 8 | elixirc_paths: ["lib"], 9 | compilers: Mix.compilers, 10 | deps: deps(), 11 | # dialyzer: [paths: ~w(tmate ranch) |> Enum.map(fn(x) -> "_build/dev/lib/#{x}/ebin" end)] 12 | ] 13 | end 14 | 15 | # Configuration for the OTP application 16 | # 17 | # Type `mix help compile.app` for more information 18 | def application do 19 | case Mix.env do 20 | :test -> [mod: {ExUnit, []}] 21 | _ -> [mod: {Tmate, []}] 22 | end 23 | end 24 | 25 | # Specifies your project dependencies 26 | # 27 | # Type `mix help deps` for examples and options 28 | defp deps do 29 | [ 30 | {:ranch, "~> 1.0"}, 31 | {:cowboy, "~> 2.0"}, 32 | {:plug, "~> 1.0"}, 33 | {:plug_cowboy, "~> 2.0"}, 34 | {:uuid, "~> 1.1" }, 35 | {:jason, ">= 0.0.0"}, 36 | {:httpoison, ">= 0.0.0"}, 37 | {:message_pack, github: "nviennot/msgpack-elixir"}, 38 | {:distillery, "~> 2.1"}, 39 | {:timex, "~> 3.5"}, 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], []}, 3 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, optional: false]}]}, 4 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], []}, 5 | "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, optional: false]}]}, 6 | "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], []}, 7 | "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, optional: false]}]}, 8 | "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], []}, 9 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, optional: false]}, {:idna, "6.0.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, optional: false]}]}, 10 | "httpoison": {:hex, :httpoison, "1.6.1", "2ce5bf6e535cd0ab02e905ba8c276580bab80052c5c549f53ddea52d72e81f33", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, optional: false]}]}, 11 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, optional: false]}]}, 12 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, optional: true]}]}, 13 | "message_pack": {:git, "https://github.com/nviennot/msgpack-elixir.git", "2f9a40afda1df77b4dd06cfeb2737643757a17ea", []}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 15 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], []}, 16 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], []}, 17 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], []}, 18 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, optional: true]}]}, 19 | "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, optional: false]}, {:plug, "~> 1.7", [hex: :plug, optional: false]}]}, 20 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], []}, 21 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], []}, 22 | "quantile_estimator": {:git, "https://github.com/nviennot/quantile_estimator.git", "b06c9782d47ebbdf2fa57aa21429f27749e58bc7", []}, 23 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], []}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], []}, 25 | "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, optional: false]}]}, 26 | "tzdata": {:hex, :tzdata, "1.0.2", "6c4242c93332b8590a7979eaf5e11e77d971e579805c44931207e32aa6ad3db1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, optional: false]}]}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], []}, 28 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], []}, 29 | } 30 | -------------------------------------------------------------------------------- /rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | ~w(rel plugins *.exs) 6 | |> Path.join() 7 | |> Path.wildcard() 8 | |> Enum.map(&Code.eval_file(&1)) 9 | 10 | use Distillery.Releases.Config, 11 | # This sets the default release built by `mix distillery.release` 12 | default_release: :default, 13 | # This sets the default environment used by `mix distillery.release` 14 | default_environment: Mix.env() 15 | 16 | # For a full list of config options for both releases 17 | # and environments, visit https://hexdocs.pm/distillery/config/distillery.html 18 | 19 | 20 | # You may define one or more environments in this file, 21 | # an environment's settings will override those of a release 22 | # when building in that environment, this combination of release 23 | # and environment configuration is called a profile 24 | 25 | environment :dev do 26 | # If you are running Phoenix, you should make sure that 27 | # server: true is set and the code reloader is disabled, 28 | # even in dev mode. 29 | # It is recommended that you build with MIX_ENV=prod and pass 30 | # the --env flag to Distillery explicitly if you want to use 31 | # dev mode. 32 | set dev_mode: true 33 | set include_erts: false 34 | end 35 | 36 | environment :prod do 37 | set include_erts: true 38 | set include_src: false 39 | set vm_args: "rel/vm.args" 40 | 41 | set config_providers: [ 42 | {Distillery.Releases.Config.Providers.Elixir, ["${RELEASE_ROOT_DIR}/etc/config.exs"]} 43 | ] 44 | set overlays: [ 45 | {:copy, "config/config.exs", "etc/config.exs"}, 46 | {:copy, "config/prod.exs", "etc/prod.exs"}, 47 | ] 48 | end 49 | 50 | # You may define one or more releases in this file. 51 | # If you have not set a default release, or selected one 52 | # when running `mix distillery.release`, the first release in the file 53 | # will be used by default 54 | 55 | release :tmate do 56 | set version: current_version(:tmate) 57 | set applications: [ 58 | :runtime_tools 59 | ] 60 | end 61 | 62 | -------------------------------------------------------------------------------- /rel/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | *.* 2 | !*.exs 3 | !.gitignore -------------------------------------------------------------------------------- /rel/vm.args: -------------------------------------------------------------------------------- 1 | ## This file provide the arguments provided to the VM at startup 2 | ## You can find a full list of flags and their behaviours at 3 | ## http://erlang.org/doc/man/erl.html 4 | 5 | ## Name of the node 6 | -name <%= release_name %>@${ERL_NODE_NAME} 7 | 8 | ## Cookie for distributed erlang 9 | -setcookie ${ERL_COOKIE} 10 | 11 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive 12 | ## (Disabled by default..use with caution!) 13 | ##-heart 14 | 15 | ## Enable kernel poll and a few async threads 16 | ##+K true 17 | ##+A 5 18 | ## For OTP21+, the +A flag is not used anymore, 19 | ## +SDio replace it to use dirty schedulers 20 | ##+SDio 5 21 | 22 | ## Increase number of concurrent ports/sockets 23 | ##-env ERL_MAX_PORTS 4096 24 | 25 | ## Tweak GC to run more often 26 | ##-env ERL_FULLSWEEP_AFTER 10 27 | 28 | # Enable SMP automatically based on availability 29 | # On OTP21+, this is not needed anymore. 30 | -smp auto 31 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # ExUnit.start() # started as an application directly 2 | -------------------------------------------------------------------------------- /test/tmate/session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.SessionTest do 2 | use ExUnit.Case, async: true 3 | alias Tmate.Session 4 | require Tmate.ProtocolDefs, as: P 5 | require Logger 6 | 7 | defmodule Daemon do 8 | def send_msg(pid, msg) do 9 | send(pid, {:daemon_msg, msg}) 10 | end 11 | end 12 | 13 | def flush do 14 | receive do 15 | _ -> flush() 16 | after 17 | 0 -> nil 18 | end 19 | end 20 | 21 | setup do 22 | {:ok, session} = Session.start_link([webhooks: [], registry: {}], {Daemon, self()}) 23 | {:ok, session: session} 24 | end 25 | 26 | defp spawn_mock_websockets(session, n) do 27 | (1..n) |> Enum.map(fn(i) -> 28 | pid = spawn fn -> :timer.sleep(:infinity) end 29 | Session.ws_request_sub(session, pid, %{ip_address: "ip#{i}"}) 30 | pid 31 | end) 32 | end 33 | 34 | test "client resizing", %{session: session} do 35 | Session.notify_daemon_msg(session, [P.tmate_ctl_header, 2, 36 | "ip", "pubkey", "stoken", "stoken_ro", "ssh_cmd_fmt", 37 | "client_version", 1]) 38 | 39 | ws = spawn_mock_websockets(session, 3) 40 | 41 | refute_received {:daemon_msg, [P.tmate_ctl_resize | _]} 42 | 43 | flush() 44 | Session.notify_resize(session, ws |> Enum.at(0), {100, 200}) 45 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 100, 200]} 46 | 47 | flush() 48 | Session.notify_resize(session, ws |> Enum.at(1), {200, 100}) 49 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 100, 100]} 50 | 51 | flush() 52 | Session.notify_resize(session, ws |> Enum.at(2), {300, 300}) 53 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 100, 100]} 54 | 55 | flush() 56 | Session.notify_resize(session, ws |> Enum.at(1), {200, 50}) 57 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 100, 50]} 58 | 59 | flush() 60 | :erlang.exit(ws |> Enum.at(1), :ok) 61 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 100, 200]} 62 | 63 | flush() 64 | :erlang.exit(ws |> Enum.at(0), :ok) 65 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, 300, 300]} 66 | 67 | flush() 68 | :erlang.exit(ws |> Enum.at(2), :ok) 69 | assert_receive {:daemon_msg, [P.tmate_ctl_resize, -1, -1]} 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/tmate/web_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WsApiTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | defp expect_plug_error(func) do 6 | try do 7 | func.() 8 | raise "No error raised" 9 | rescue _e in Plug.Conn.WrapperError -> 10 | nil 11 | end 12 | end 13 | 14 | defp register_new_session(registry, id, stoken, stoken_ro) do 15 | {registry_mod, registry_pid} = registry 16 | pid = spawn(fn -> receive do end end) 17 | registry_mod.register_session(registry_pid, pid, id, stoken, stoken_ro) 18 | end 19 | 20 | setup do 21 | registry = {Tmate.SessionRegistry, Tmate.SessionRegistry.WsApiTest} 22 | Tmate.SessionRegistry.start_link([name: Tmate.SessionRegistry.WsApiTest]) 23 | 24 | session_opts = [webhooks: [], registry: registry] 25 | router = fn conn -> Tmate.WsApi.Router.call(conn, session_opts) end 26 | {:ok, router: router, registry: registry} 27 | end 28 | 29 | describe "/internal_api/get_stale_sessions" do 30 | test "authentication required", %{router: router} do 31 | conn = conn(:post, "/internal_api/get_stale_sessions", %{}) 32 | expect_plug_error(fn -> router.(conn) end) 33 | {status, _, _} = sent_resp(conn) 34 | assert status == 401 35 | 36 | conn = conn(:post, "/internal_api/get_stale_sessions", %{auth_key: "xxx"}) 37 | expect_plug_error(fn -> router.(conn) end) 38 | {status, _, _} = sent_resp(conn) 39 | assert status == 401 40 | end 41 | 42 | test "get stale sessions", %{router: router, registry: registry} do 43 | s1 = UUID.uuid1() 44 | s2 = UUID.uuid1() 45 | s3 = UUID.uuid1() 46 | s4 = UUID.uuid1() 47 | 48 | register_new_session(registry, s1, "s1", "s1ro") 49 | register_new_session(registry, s2, "s2", "s2ro") 50 | 51 | auth_token = "internal_api_auth_token" 52 | payload = %{session_ids: [s3, s4]} 53 | conn = conn(:post, "/internal_api/get_stale_sessions", payload) 54 | |> put_req_header("authorization", "Bearer " <> auth_token) 55 | |> router.() 56 | 57 | assert conn.status == 200 58 | body = Jason.decode!(conn.resp_body) 59 | assert body == %{"stale_ids" => [s3, s4]} 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/tmate/webhook_events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WebhookEventsTest do 2 | use ExUnit.Case, async: true 3 | alias Tmate.Session 4 | require Tmate.ProtocolDefs, as: P 5 | require Logger 6 | 7 | defmodule Webhook.Mock do 8 | use GenServer 9 | 10 | def start_link(test_pid, opts \\ []) do 11 | GenServer.start_link(__MODULE__, test_pid, opts) 12 | end 13 | 14 | def init(test_pid) do 15 | state = %{test_pid: test_pid} 16 | Process.flag(:trap_exit, true) 17 | {:ok, state} 18 | end 19 | 20 | def emit_event(pid, event, _opts \\ []) do 21 | GenServer.cast(pid, {:emit_event, event}) 22 | end 23 | 24 | def handle_cast({:emit_event, event}, %{test_pid: test_pid}=state) do 25 | event = Jason.decode!(Jason.encode!(event)) 26 | send(test_pid, {:webhook_event, event}) 27 | {:noreply, state} 28 | end 29 | end 30 | 31 | defmodule Daemon do 32 | def send_msg(pid, msg) do 33 | send(pid, {:daemon_msg, msg}) 34 | end 35 | end 36 | 37 | def start_link_session() do 38 | webhooks = [{Webhook.Mock, self()}] 39 | session_opts = [webhooks: webhooks, registry: {}] 40 | {:ok, session} = Session.start_link(session_opts, {Daemon, self()}) 41 | session 42 | end 43 | 44 | test "events" do 45 | session = start_link_session() 46 | Session.notify_daemon_msg(session, [P.tmate_ctl_header, 2, 47 | "ip", "pubkey", "stoken", "stoken_ro", "ssh_cmd_fmt", 48 | "client_version", 6]) 49 | 50 | Session.notify_daemon_msg(session, [P.tmate_ctl_deamon_out_msg, [P.tmate_out_ready]]) 51 | assert_receive {:webhook_event, 52 | %{"type" => "session_register", 53 | "entity_id" => session_id, 54 | "generation" => 1, 55 | "params" => %{ 56 | "stoken" => "stoken", 57 | "stoken_ro" => "stoken_ro", 58 | "ssh_cmd_fmt" => "ssh_cmd_fmt", 59 | "reconnected" => false, 60 | "ip_address" => "ip", 61 | "client_version" => "client_version" 62 | } 63 | }} 64 | assert_receive {:daemon_msg, [P.tmate_ctl_deamon_fwd_msg, 65 | [P.tmate_in_set_env, "tmate_reconnection_data", reconnection_data]]} 66 | 67 | Session.notify_daemon_msg(session, [P.tmate_ctl_client_join, 33, "c1ip", "c1pubkey", false]) 68 | assert_receive {:webhook_event, 69 | %{"type" => "session_join", 70 | "entity_id" => ^session_id, 71 | "generation" => 1, 72 | "params" => %{ 73 | "id" => c1_id, 74 | "readonly" => false, 75 | "type" => "ssh", 76 | "ip_address" => "c1ip" 77 | } 78 | }} 79 | 80 | Process.unlink(session) 81 | Process.exit(session, {:shutdown, :tcp_close}) 82 | assert_receive {:webhook_event, 83 | %{"type" => "session_disconnect", 84 | "entity_id" => ^session_id, 85 | "generation" => 1, 86 | "params" => %{} 87 | }} 88 | 89 | session = start_link_session() 90 | Session.notify_daemon_msg(session, [P.tmate_ctl_header, 2, 91 | "ip", "pubkey", "stoken", "stoken_ro", "ssh_cmd_fmt", 92 | "client_version", 6]) 93 | Session.notify_daemon_msg(session, [P.tmate_ctl_deamon_out_msg, 94 | [P.tmate_out_reconnect, reconnection_data]]) 95 | Session.notify_daemon_msg(session, [P.tmate_ctl_deamon_out_msg, [P.tmate_out_ready]]) 96 | assert_receive {:webhook_event, 97 | %{"type" => "session_register", 98 | "entity_id" => ^session_id, 99 | "generation" => 2, 100 | "params" => %{ 101 | "stoken" => "stoken", 102 | "stoken_ro" => "stoken_ro", 103 | "ssh_cmd_fmt" => "ssh_cmd_fmt", 104 | "reconnected" => true, 105 | "ip_address" => "ip", 106 | "client_version" => "client_version" 107 | } 108 | }} 109 | 110 | Session.notify_daemon_msg(session, [P.tmate_ctl_client_join, 34, "c2ip", "c2pubkey", true]) 111 | assert_receive {:webhook_event, 112 | %{"type" => "session_join", 113 | "entity_id" => ^session_id, 114 | "generation" => 2, 115 | "params" => %{ 116 | "id" => c2_id, 117 | "readonly" => true, 118 | "type" => "ssh", 119 | "ip_address" => "c2ip" 120 | } 121 | }} 122 | 123 | Session.notify_daemon_msg(session, [P.tmate_ctl_client_left, 34]) 124 | assert_receive {:webhook_event, 125 | %{"type" => "session_left", 126 | "entity_id" => ^session_id, 127 | "generation" => 2, 128 | "params" => %{ 129 | "id" => ^c2_id 130 | } 131 | }} 132 | 133 | Process.unlink(session) 134 | Session.notify_daemon_msg(session, [P.tmate_ctl_deamon_out_msg, [P.tmate_out_fin]]) 135 | assert_receive {:webhook_event, 136 | %{"type" => "session_close", 137 | "entity_id" => ^session_id, 138 | "generation" => 2, 139 | "params" => %{} 140 | }} 141 | end 142 | end 143 | --------------------------------------------------------------------------------