├── .formatter.exs ├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── README.md ├── assets ├── .babelrc ├── css │ └── app.css ├── js │ ├── app.js │ ├── socket.js │ └── term │ │ ├── pane.js │ │ ├── session.js │ │ ├── term.css │ │ ├── window.js │ │ └── xterm_constants.ts ├── package-lock.json ├── package.json ├── static │ ├── css │ │ ├── bootstrap.min.css │ │ └── main.css │ ├── img │ │ ├── 1_step.png │ │ ├── 2_step.png │ │ ├── 3_step.png │ │ ├── arch.svg │ │ ├── digitalocean.png │ │ ├── fork-me-on-github-right-orange@2x.png │ │ ├── github-btn.html │ │ ├── glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2 │ │ ├── glyphicons-halflings-regular.89889688147bd7575d6327160d64e760.svg │ │ ├── glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf │ │ ├── glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot │ │ ├── glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff │ │ ├── icon.png │ │ ├── logo.png │ │ ├── mac_notr.png │ │ ├── mac_tr.png │ │ ├── rebel.png │ │ ├── tmate.svg │ │ ├── video_linux.png │ │ ├── video_linux_first_frame.png │ │ ├── video_mac.png │ │ ├── video_macos.png │ │ └── video_macos_first_frame.png │ └── js │ │ ├── bootstrap.min.js │ │ ├── index.js │ │ ├── jquery-2.0.2.min.js │ │ └── main.js └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── tmate.ex ├── tmate │ ├── application.ex │ ├── event_projections.ex │ ├── event_projections │ │ ├── session.ex │ │ └── user.ex │ ├── mailer.ex │ ├── models │ │ ├── client.ex │ │ ├── event.ex │ │ ├── session.ex │ │ └── user.ex │ ├── monitoring.ex │ ├── monitoring_collector.ex │ ├── repo.ex │ ├── scheduler.ex │ ├── session_cleaner.ex │ ├── util │ │ ├── ecto_helpers.ex │ │ ├── json_api.ex │ │ ├── plug_remote_ip.ex │ │ └── plug_verify_auth_token.ex │ └── ws_api.ex ├── tmate_web.ex └── tmate_web │ ├── channels │ └── user_socket.ex │ ├── controllers │ ├── internal_api_controller.ex │ ├── sign_up_controller.ex │ └── terminal_controller.ex │ ├── emails │ └── email.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ ├── templates │ ├── email │ │ ├── api_key.html.eex │ │ └── api_key.text.eex │ ├── layout │ │ ├── app.html.eex │ │ └── email.html.eex │ ├── sign_up │ │ └── home.html.eex │ └── terminal │ │ └── show.html.eex │ └── views │ ├── email_view.ex │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ ├── sign_up_view.ex │ └── terminal_view.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── console.exs │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20151010162127_initial.exs │ │ ├── 20151221142603_key_size.exs │ │ ├── 20160121023039_add_identity_metadata.exs │ │ ├── 20160123063003_add_connection_fmt.exs │ │ ├── 20160304084101_add_client_stats.exs │ │ ├── 20160328175128_client_id_uuid.exs │ │ ├── 20160406210826_github_users.exs │ │ ├── 20190904041603_add_disconnect_at.exs │ │ ├── 20191005234200_add_generation.exs │ │ ├── 20191014044039_add_closed_at.exs │ │ ├── 20191108161753_remove_identity_one.exs │ │ ├── 20191108174232_remove_identity_three.exs │ │ ├── 20191110232601_remove_github_id.exs │ │ ├── 20191110232704_expand_token_size.exs │ │ ├── 20191111025821_add_api_key.exs │ │ └── 20200725202312_add_generation_session.exs │ ├── seeds.exs │ └── structure.sql └── static │ ├── css │ ├── bootstrap.min.css │ └── main.css │ ├── img │ ├── 1_step.png │ ├── 2_step.png │ ├── 3_step.png │ ├── arch.svg │ ├── digitalocean.png │ ├── fork-me-on-github-right-orange@2x.png │ ├── github-btn.html │ ├── glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2 │ ├── glyphicons-halflings-regular.89889688147bd7575d6327160d64e760.svg │ ├── glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf │ ├── glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot │ ├── glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff │ ├── mac_notr.png │ ├── mac_tr.png │ ├── rebel.png │ ├── video_linux.png │ ├── video_linux_first_frame.png │ ├── video_mac.png │ ├── video_macos.png │ └── video_macos_first_frame.png │ └── js │ ├── bootstrap.min.js │ ├── index.js │ ├── jquery-2.0.2.min.js │ └── main.js ├── rel ├── config.exs ├── plugins │ └── .gitignore └── vm.args └── test ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex ├── event_case.ex └── factory.ex ├── test_helper.exs └── tmate_web ├── controllers ├── internal_api_controller_test.exs ├── page_controller_test.exs └── sign_up_controller_test.exs ├── event_projections └── session_test.exs └── views ├── error_view_test.exs ├── layout_view_test.exs └── page_view_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | tmate-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | -------------------------------------------------------------------------------- /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 npm 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 assets/package-lock.json assets/package-lock.json 17 | COPY assets/package.json assets/package.json 18 | RUN cd assets && npm install 19 | 20 | COPY assets assets 21 | RUN cd assets && npm run deploy 22 | 23 | COPY lib lib 24 | COPY test test 25 | COPY config config 26 | COPY priv/gettext priv/gettext 27 | COPY priv/repo priv/repo 28 | COPY rel rel 29 | 30 | ENV HOSTNAME "master-0" 31 | RUN mix do phx.digest, distillery.release --no-tar && \ 32 | mkdir _build/lib-layer && \ 33 | mv _build/prod/rel/tmate/lib/tmate* _build/lib-layer 34 | 35 | ### Minimal run-time image 36 | FROM alpine:3.9 37 | 38 | RUN apk --no-cache add ncurses-libs openssl ca-certificates bash 39 | 40 | RUN adduser -D app 41 | 42 | ENV MIX_ENV prod 43 | 44 | WORKDIR /opt/app 45 | 46 | # Copy release from build stage 47 | # We copy in two passes to benefit from docker layers 48 | # Note "COPY some_dir dst" will copy the content of some_dir into dst 49 | COPY --from=build /build/_build/prod/rel/* . 50 | COPY --from=build /build/_build/lib-layer lib/ 51 | 52 | USER app 53 | 54 | RUN mkdir /tmp/app 55 | ENV RELEASE_MUTABLE_DIR /tmp/app 56 | ENV REPLACE_OS_VARS true 57 | 58 | # Start command 59 | CMD ["/opt/app/bin/tmate", "foreground"] 60 | -------------------------------------------------------------------------------- /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 inotify-tools npm 5 | 6 | WORKDIR /src/tmate-master 7 | 8 | COPY mix.exs . 9 | COPY mix.lock . 10 | 11 | ARG MIX_ENV=dev 12 | ENV MIX_ENV ${MIX_ENV} 13 | 14 | RUN mix deps.get 15 | RUN mix deps.compile 16 | 17 | COPY assets/package-lock.json assets/package-lock.json 18 | COPY assets/package.json assets/package.json 19 | RUN cd assets && npm install 20 | 21 | COPY assets assets 22 | COPY config config 23 | COPY lib lib 24 | COPY priv/gettext priv/gettext 25 | COPY priv/repo priv/repo 26 | 27 | RUN mix compile 28 | 29 | CMD mkfifo console; sleep 1000d > console & cat console | \ 30 | iex --name master@master \ 31 | --cookie cookie \ 32 | --erl '-kernel inet_dist_listen_min 20000' \ 33 | --erl '-kernel inet_dist_listen_max 20000' \ 34 | -S mix phx.server 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tmate 2 | 3 | To start your Phoenix server: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Create and migrate your database with `mix ecto.setup` 7 | * Install Node.js dependencies with `cd assets && npm install` 8 | * Start Phoenix endpoint with `mix phx.server` 9 | 10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 11 | 12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 13 | 14 | ## Learn more 15 | 16 | * Official website: http://www.phoenixframework.org/ 17 | * Guides: https://hexdocs.pm/phoenix/overview.html 18 | * Docs: https://hexdocs.pm/phoenix 19 | * Mailing list: http://groups.google.com/group/phoenix-talk 20 | * Source: https://github.com/phoenixframework/phoenix 21 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | 3 | /* @import "./phoenix.css"; */ 4 | 5 | /* All the css is in the assets/static/css folder */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | //import css from "../css/app.css" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import dependencies 11 | // 12 | //import "phoenix_html" 13 | 14 | // Import local files 15 | // 16 | // Local files can be imported directly using relative paths, for example: 17 | // import socket from "./socket" 18 | 19 | import Session from './term/session' 20 | 21 | import ReactDOM from 'react-dom'; 22 | import React from 'react'; 23 | 24 | function initTerminal(rootElement, token) 25 | { 26 | ReactDOM.render(, rootElement); 27 | } 28 | 29 | window.initTerminal = initTerminal; 30 | -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // Now that you are connected, you can join channels with a topic: 58 | let channel = socket.channel("topic:subtopic", {}) 59 | channel.join() 60 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /assets/js/term/pane.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from 'react-dom'; 3 | import { Terminal } from 'xterm'; 4 | import 'xterm/css/xterm.css'; 5 | import { Attributes, FgFlags, BgFlags } from "./xterm_constants.ts"; 6 | 7 | /* tmux grid attrs */ 8 | const GRID_ATTR_BRIGHT = 0x1 9 | const GRID_ATTR_DIM = 0x2 10 | const GRID_ATTR_UNDERSCORE = 0x4 11 | const GRID_ATTR_BLINK = 0x8 12 | const GRID_ATTR_REVERSE = 0x10 13 | const GRID_ATTR_HIDDEN = 0x20 14 | const GRID_ATTR_ITALICS = 0x40 15 | const GRID_ATTR_CHARSET = 0x80 16 | 17 | /* tmux modes */ 18 | const MODE_CURSOR = 0x1 19 | const MODE_INSERT = 0x2 20 | const MODE_KCURSOR = 0x4 21 | const MODE_KKEYPAD = 0x8 /* set = application, clear = number */ 22 | const MODE_WRAP = 0x10 /* whether lines wrap */ 23 | const MODE_MOUSE_STANDARD = 0x20 24 | const MODE_MOUSE_BUTTON = 0x40 25 | const MODE_MOUSE_ANY = 0x80 26 | const MODE_MOUSE_UTF8 = 0x100 27 | const MODE_MOUSE_SGR = 0x200 28 | const MODE_BRACKETPASTE = 0x400 29 | const MODE_FOCUSON = 0x800 30 | const ALL_MOUSE_MODES = (MODE_MOUSE_STANDARD|MODE_MOUSE_BUTTON|MODE_MOUSE_ANY) 31 | 32 | export default class Pane extends React.Component { 33 | componentDidMount() { 34 | if (this.term === undefined) { 35 | let term = new Terminal({ 36 | // screenKeys: true, 37 | cursorBlink: false, 38 | rows: this.props.rows, 39 | cols: this.props.cols, 40 | disableStdin: false, /* set with readonly mode */ 41 | fontFamily: "DejaVu Sans Mono, Liberation Mono, monospace", 42 | fontSize: 12, 43 | // lineHeight: 14/12, 44 | macOptionIsMeta: true, 45 | LogLevel: 'debug' 46 | // useFocus: false, 47 | // tmate_pane: this 48 | }); 49 | 50 | 51 | /* TODO onBinary */ 52 | term.onData(data => { 53 | this.props.session.send_pty_keys(this.props.id, data) 54 | }) 55 | 56 | window.term = term 57 | // term.debug = true 58 | // term.on('error', msg => console.log(`error: ${msg}`)) 59 | 60 | term.open(ReactDOM.findDOMNode(this)) 61 | 62 | const dims = term._core._renderService.dimensions; 63 | this.props.session.set_char_size(dims.actualCellWidth, dims.actualCellHeight); 64 | 65 | if (this.props.active) 66 | term.focus() 67 | 68 | this.term = term 69 | 70 | this.props.session.get_pane_event_buffer(this.props.id).set_handler({ 71 | on_pty_data: this.on_pty_data.bind(this), 72 | on_bootstrap_grid: this.on_bootstrap_grid.bind(this), 73 | on_sync_copy_mode: this.on_sync_copy_mode.bind(this), 74 | }) 75 | } 76 | } 77 | 78 | render() { 79 | return
80 | } 81 | 82 | on_focus() { 83 | if (this.props.window.props.active_pane_id !== this.props.id) 84 | this.props.session.focus_pane(this.props.id) 85 | } 86 | 87 | componentDidUpdate() { 88 | this.term.resize(this.props.cols, this.props.rows) 89 | 90 | if (this.props.active) 91 | this.term.focus() 92 | 93 | this.term._core.cursorHidden = !this.props.active; 94 | this.term.refresh(this.term.buffer.cursorY, this.term.buffer.cursorY); 95 | } 96 | 97 | componentWillUnmount() { 98 | this.props.session.on_umount_pane(this.props.id) 99 | if (this.term) { 100 | this.term.dispose() 101 | this.term = undefined 102 | } 103 | } 104 | 105 | on_bootstrap_grid(...args) { 106 | bootstrap_grid(this.term, ...args) 107 | } 108 | 109 | on_pty_data(data) { 110 | this.term.write(data) 111 | } 112 | 113 | on_sync_copy_mode(data) { 114 | const term = this.term 115 | const select = term._core._selectionService; 116 | const top_line_offset = term.buffer._buffer.lines.length - term.rows; 117 | 118 | if (data && data.length == 0) { 119 | term._core.buffer.ydisp = top_line_offset 120 | select.clearSelection(); 121 | term.refresh(0, term.rows-1); 122 | return; 123 | } 124 | 125 | let [backing, oy, cx, cy, sel, status] = data; 126 | 127 | if (sel && sel.length > 0) { 128 | let [selx, sely, flags] = sel 129 | sely = term.rows - sely 130 | select._model.selectionStart = [cx+1, top_line_offset + cy-oy] 131 | select._model.selectionEnd = [selx, top_line_offset + sely-1] 132 | select.refresh(); 133 | } 134 | 135 | let new_ydisp = Math.max(top_line_offset - oy, 0) 136 | if (term._core.buffer.ydisp != new_ydisp) { 137 | term._core.buffer.ydisp = new_ydisp; 138 | term.refresh(0, term.rows-1); 139 | } 140 | } 141 | } 142 | 143 | const bootstrap_grid = (term, cursor_pos, mode, grid_data) => { 144 | const term_attr = packed_attrs => { 145 | let fg = packed_attrs & 0xFF 146 | let bg = (packed_attrs >> 8) & 0xFF 147 | let attr = (packed_attrs >> 16) & 0xFF 148 | let flags = (packed_attrs >> 24) & 0xFF 149 | 150 | if (fg != 8) 151 | fg |= Attributes.CM_P256 152 | if (bg != 8) 153 | bg |= Attributes.CM_P256 154 | 155 | if (attr & GRID_ATTR_BRIGHT) fg |= FgFlags.BOLD 156 | if (attr & GRID_ATTR_UNDERSCORE) fg |= FgFlags.UNDERLINE 157 | if (attr & GRID_ATTR_BLINK) fg |= FgFlags.BLINK 158 | if (attr & GRID_ATTR_REVERSE) fg |= FgFlags.INVERSE 159 | if (attr & GRID_ATTR_HIDDEN) fg |= FgFlags.INVISIBLE 160 | if (attr & GRID_ATTR_DIM) bg |= BgFlags.DIM 161 | if (attr & GRID_ATTR_ITALICS) bg |= BgFlags.ITALIC 162 | 163 | return [fg, bg] 164 | } 165 | 166 | const [cursor_x, cursor_y] = cursor_pos 167 | const [cols, rows] = [term.rows, term.cols] 168 | const buffer = term.buffer._buffer; 169 | 170 | /* 171 | * This gets us an empty line. We should call 172 | * getBlankLine(DEFAULT_ATTR_DATA), but we don't have access 173 | * to the constant. 174 | */ 175 | buffer.clear(); 176 | buffer.fillViewportRows(); 177 | const emptyLine = buffer.lines.get(0).clone(); 178 | 179 | grid_data.forEach((line_data, i) => { 180 | const [chars, attrs] = line_data 181 | 182 | let line = emptyLine.clone(); 183 | 184 | for (let j = 0; j < chars.length; j++) { 185 | let c = chars.charCodeAt(j) 186 | const width = 1; /* TODO */ 187 | const [fg, bg] = term_attr(attrs[j]) 188 | line.setCellFromCodePoint(j, c, width, fg, bg) 189 | } 190 | 191 | buffer.lines.push(line) 192 | }); 193 | 194 | buffer.ydisp = grid_data.length 195 | buffer.ybase = buffer.ydisp 196 | 197 | buffer.x = cursor_x 198 | buffer.y = cursor_y 199 | 200 | const core = term._core 201 | // if (mode & MODE_CURSOR) 202 | // core.applicationCursor = true 203 | core.wraparoundMode = !!(mode & MODE_WRAP) 204 | if (mode & ALL_MOUSE_MODES) 205 | core._coreMouseService.activeProtocol = "VT200" 206 | core.sendFocus = !!(mode & MODE_FOCUSON) 207 | // if (mode & MODE_MOUSE_UTF8) 208 | // core.utfMouse = true 209 | // if (mode & MODE_MOUSE_SGR) 210 | // core.sgrMouse = true 211 | core.cursorHidden = !(mode & MODE_CURSOR); 212 | 213 | term.refresh(0, term.rows - 1); 214 | } 215 | -------------------------------------------------------------------------------- /assets/js/term/session.js: -------------------------------------------------------------------------------- 1 | import "./term.css" 2 | 3 | import React from "react" 4 | import ReactDOM from 'react-dom'; 5 | import $ from "jquery" 6 | import msgpack from "msgpack-js-browser" 7 | 8 | import Window from "./window" 9 | 10 | const TMATE_WS_DAEMON_OUT_MSG = 0 11 | const TMATE_WS_SNAPSHOT = 1 12 | 13 | const TMATE_WS_PANE_KEYS = 0 14 | const TMATE_WS_EXEC_CMD = 1 15 | const TMATE_WS_RESIZE = 2 16 | 17 | const TMATE_OUT_HEADER = 0 18 | const TMATE_OUT_SYNC_LAYOUT = 1 19 | const TMATE_OUT_PTY_DATA = 2 20 | const TMATE_OUT_EXEC_CMD_STR = 3 21 | const TMATE_OUT_FAILED_CMD = 4 22 | const TMATE_OUT_STATUS = 5 23 | const TMATE_OUT_SYNC_COPY_MODE = 6 24 | const TMATE_OUT_WRITE_COPY_MODE = 7 25 | const TMATE_OUT_FIN = 8 26 | const TMATE_OUT_READY = 9 27 | const TMATE_OUT_RECONNECT = 10 28 | const TMATE_OUT_SNAPSHOT = 11 29 | const TMATE_OUT_EXEC_CMD = 12 30 | 31 | const WS_CONNECTING = 0 32 | const WS_BOOTSTRAPPING = 1 33 | const WS_OPEN = 2 34 | const WS_CLOSED = 3 35 | const WS_RECONNECT = 4 36 | 37 | const WS_ERRORS = { 38 | 1005: "Session closed", 39 | 1006: "Connection failed", 40 | } 41 | 42 | class EventBuffer { 43 | constructor() { 44 | this.pending_events = [] 45 | this.handlers = null 46 | } 47 | 48 | send(f, args) { 49 | if (this.handlers) 50 | this.handlers[f].apply(null, args) 51 | else 52 | this.pending_events.push([f, args]) 53 | } 54 | 55 | set_handler(handlers) { 56 | this.handlers = handlers 57 | this.pending_events.forEach(([f, args]) => this.send(f, args)) 58 | this.pending_events = null 59 | } 60 | } 61 | 62 | export default class Session extends React.Component { 63 | state = { ws_state: WS_CONNECTING } 64 | 65 | constructor() { 66 | super() 67 | this.char_size = {width: 7, height: 14}; 68 | /* From the padding in term.css : .terminal */ 69 | this.terminal_padding_size = {width: 1*2, height: 4*2}; 70 | } 71 | 72 | componentDidMount() { 73 | this.connect() 74 | 75 | this.ws_handlers = new Map() 76 | this.ws_handlers.set(TMATE_WS_DAEMON_OUT_MSG, this.on_ws_daemon_out_msg) 77 | this.ws_handlers.set(TMATE_WS_SNAPSHOT, this.on_ws_snapshot) 78 | 79 | this.daemon_handlers = new Map() 80 | this.daemon_handlers.set(TMATE_OUT_SYNC_LAYOUT, this.on_sync_layout) 81 | this.daemon_handlers.set(TMATE_OUT_PTY_DATA, this.on_pty_data) 82 | this.daemon_handlers.set(TMATE_OUT_STATUS, this.on_status) 83 | this.daemon_handlers.set(TMATE_OUT_FIN, this.on_fin) 84 | this.daemon_handlers.set(TMATE_OUT_SYNC_COPY_MODE, this.on_sync_copy_mode) 85 | 86 | this.pane_events = new Map() 87 | 88 | this.handleResize = this.handleResize.bind(this); 89 | window.addEventListener('resize', this.handleResize); 90 | } 91 | 92 | componentWillUnmount() { 93 | window.removeEventListener('resize', this.handleResize); 94 | this.disconnect() 95 | } 96 | 97 | set_char_size(width, height) { 98 | if (width && height && width > 0 && height > 0) { 99 | const old_size = this.char_size; 100 | this.char_size = {width, height}; 101 | if (old_size.width != width || old_size.height != height) 102 | this.forceUpdate(); 103 | } 104 | } 105 | 106 | get_row_width(num_chars) { 107 | return this.char_size.width * num_chars 108 | } 109 | 110 | get_col_height(num_chars) { 111 | return this.char_size.height * num_chars 112 | } 113 | 114 | get_pane_event_buffer(pane_id) { 115 | let e = this.pane_events.get(pane_id) 116 | if (!e) { 117 | e = new EventBuffer() 118 | this.pane_events.set(pane_id, e) 119 | } 120 | return e 121 | } 122 | 123 | emit_pane_event(pane_id, f, ...args) { 124 | this.get_pane_event_buffer(pane_id).send(f, args) 125 | } 126 | 127 | on_umount_pane(pane_id) { 128 | this.pane_events.delete(pane_id) 129 | } 130 | 131 | connect() { 132 | this.setState({ws_state: WS_CONNECTING}) 133 | 134 | $.get(`/api/t/${this.props.token}`) 135 | .fail((jqXHR, textStatus, error) => { 136 | // TODO retry when needed here as well. 137 | this.setState({ws_state: WS_CLOSED, close_reason: `Error: ${error}`}) 138 | }) 139 | .done(session => { 140 | if (session.closed_at) { 141 | this.setState({ws_state: WS_CLOSED, close_reason: "Session closed"}) 142 | } else if (this.state.ws_state != WS_CLOSED) { 143 | this.setState({ws_url_fmt: session.ws_url_fmt, ssh_cmd_fmt: session.ssh_cmd_fmt}) 144 | const ws_url = session.ws_url_fmt.replace("%s", this.props.token); 145 | this.ws = new WebSocket(ws_url) 146 | this.ws.binaryType = "arraybuffer" 147 | this.ws.onmessage = event => { 148 | this.on_socket_msg(this.deserialize_msg(event.data)) 149 | } 150 | this.ws.onopen = event => { 151 | this.handleResize() 152 | this.setState({ws_state: WS_BOOTSTRAPPING}) 153 | } 154 | this.ws.onclose = event => { 155 | this.reconnect() 156 | } 157 | this.ws.onerror = event => { 158 | this.reconnect() 159 | } 160 | } 161 | }) 162 | } 163 | 164 | reconnect() { 165 | if (this.state.ws_state == WS_RECONNECT || this.state.ws_state == WS_CLOSED) 166 | return; 167 | 168 | setTimeout(this.connect.bind(this), 1000); 169 | this.setState({ws_state: WS_RECONNECT}) 170 | } 171 | 172 | disconnect() { 173 | this.setState({ws_state: WS_CLOSED}) 174 | if (this.ws) { 175 | this.ws.close() 176 | this.ws = undefined 177 | } 178 | } 179 | 180 | on_socket_msg(msg) { 181 | const [cmd, ...rest] = msg 182 | const h = this.ws_handlers.get(cmd) 183 | if (h) { h.apply(this, rest) } 184 | } 185 | 186 | on_ws_daemon_out_msg(msg) { 187 | const [cmd, ...rest] = msg 188 | const h = this.daemon_handlers.get(cmd) 189 | if (h) { h.apply(this, rest) } 190 | } 191 | 192 | on_ws_snapshot(panes) { 193 | for (const pane of panes) { 194 | const [pane_id, ...rest] = pane 195 | this.emit_pane_event(pane_id, "on_bootstrap_grid", ...rest) 196 | } 197 | 198 | this.setState({ws_state: WS_OPEN}) 199 | } 200 | 201 | on_sync_layout(session_x, session_y, windows, active_window_id) { 202 | this.setState({ 203 | windows: windows, 204 | size: [session_x, session_y], 205 | active_window_id: active_window_id 206 | }) 207 | } 208 | 209 | on_pty_data(pane_id, data) { 210 | this.emit_pane_event(pane_id, "on_pty_data", data) 211 | } 212 | 213 | on_sync_copy_mode(pane_id, data) { 214 | this.emit_pane_event(pane_id, "on_sync_copy_mode", data) 215 | } 216 | 217 | render_message(msg) { 218 | return
219 |

{msg}

220 |
221 | } 222 | 223 | render_ws_connecting() { 224 | return this.render_message("Connecting...") 225 | } 226 | 227 | render_ws_bootstrapping() { 228 | return this.render_message("Initializing session...") 229 | } 230 | 231 | render_ws_closed() { 232 | const event = this.state.ws_close_event 233 | const close_reason = this.state.close_reason || 234 | (event ? WS_ERRORS[this.state.ws_close_event.code] || 235 | `Not connected: ${event.code} ${event.reason}` : 236 | "Not connected") 237 | return this.render_message(close_reason) 238 | } 239 | 240 | render_ws_reconnect() { 241 | return this.render_message("Reconnecting...") 242 | } 243 | 244 | render_ws_open() { 245 | let win_nav = null; 246 | if (this.state.windows.length > 1) { 247 | const nav_elems = this.state.windows.map(win => { 248 | const [id, title, panes, active_pane_id] = win 249 | const active = this.state.active_window_id === id 250 | return
  • 251 | 252 | {id}  253 | {title} 254 |
  • 255 | }) 256 | win_nav = 257 | } 258 | 259 | const wins = this.state.windows.map(win => { 260 | const [id, title, panes, active_pane_id] = win 261 | const active = this.state.active_window_id === id 262 | const wrapper_style = active ? {} : {display: 'none'} 263 | return
    264 | 266 |
    267 | }) 268 | 269 | const session_style = {width: this.get_row_width(this.state.size[0])} 270 | const session_div =
    271 | {win_nav} 272 |
    273 | {wins} 274 |
    275 | 276 | return
    277 | {session_div} 278 | {this.render_ssh_connection_string()} 279 |

    280 | The HTML5 client is a work in progress. tmux key bindings don't work. There are known graphical bugs. 281 |

    282 |
    283 | } 284 | 285 | render_ssh_connection_string() { 286 | if (!this.state.ssh_cmd_fmt) 287 | return 288 | 289 | const ssh_cmd = this.state.ssh_cmd_fmt.replace("%s", this.props.token); 290 | 291 | return

    292 | {ssh_cmd} 293 |

    294 | } 295 | 296 | componentDidUpdate(prevProps, prevState) { 297 | this.handleResize() 298 | } 299 | 300 | handleResize(_event) { 301 | if (this.state.ws_state != WS_OPEN) 302 | return 303 | 304 | let max_width = window.innerWidth 305 | let max_height = window.innerHeight 306 | 307 | if (this.refs.top_win) 308 | max_height -= ReactDOM.findDOMNode(this.refs.top_win).getBoundingClientRect().top 309 | 310 | max_width -= this.terminal_padding_size.width 311 | max_height -= this.terminal_padding_size.height 312 | 313 | this.notify_client_size(Math.floor(max_width / this.char_size.width), 314 | Math.floor(max_height / this.char_size.height)) 315 | } 316 | 317 | render() { 318 | switch (this.state.ws_state) { 319 | case WS_CONNECTING: return this.render_ws_connecting() 320 | case WS_BOOTSTRAPPING: return this.render_ws_bootstrapping() 321 | case WS_OPEN: return this.render_ws_open() 322 | case WS_CLOSED: return this.render_ws_closed() 323 | case WS_RECONNECT: return this.render_ws_reconnect() 324 | } 325 | } 326 | 327 | on_click_win_tab(win_id, event) { 328 | if (this.state.active_window_id !== win_id) 329 | this.focus_window(win_id) 330 | } 331 | 332 | send_pty_keys(pane_id, data) { 333 | this.send_msg([TMATE_WS_PANE_KEYS, pane_id, data]) 334 | } 335 | 336 | notify_client_size(x, y) { 337 | if (this.last_x !== x || this.last_y !== y) { 338 | this.send_msg([TMATE_WS_RESIZE, [x,y]]) 339 | this.last_x = x 340 | this.last_y = y 341 | } 342 | } 343 | 344 | focus_window(win_id) { 345 | this.send_msg([TMATE_WS_EXEC_CMD, `select-window -t ${win_id}`]) 346 | } 347 | 348 | focus_pane(pane_id) { 349 | this.send_msg([TMATE_WS_EXEC_CMD, `select-pane -t %${pane_id}`]) 350 | } 351 | 352 | send_msg(msg) { 353 | if (this.state.ws_state == WS_OPEN) 354 | this.ws.send(this.serialize_msg(msg)); 355 | } 356 | 357 | on_status(msg) { 358 | // TODO 359 | // console.log(`Got status: ${msg}`) 360 | } 361 | 362 | on_fin() { 363 | this.setState({ws_state: WS_CLOSED, close_reason: "Session closed"}) 364 | } 365 | 366 | serialize_msg(msg) { 367 | return msgpack.encode(msg) 368 | } 369 | 370 | deserialize_msg(msg) { 371 | return msgpack.decode(msg) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /assets/js/term/term.css: -------------------------------------------------------------------------------- 1 | .session-status h3 { 2 | text-align: center; 3 | } 4 | 5 | .session span.win-id { 6 | color: #666; 7 | } 8 | 9 | .session { 10 | margin: 5px auto; 11 | } 12 | 13 | .session .nav-tabs { 14 | margin-bottom: 0; 15 | } 16 | 17 | .session .nav-tabs > li > a { 18 | padding: 3px 6px 3px 6px; 19 | } 20 | 21 | .window { 22 | position: relative; 23 | margin-bottom: 8px; 24 | } 25 | 26 | .pane_container { 27 | position: absolute; 28 | } 29 | 30 | .pane_container.active { 31 | box-shadow: rgba(255, 255, 255, 0.40) 0 0px 7px; 32 | } 33 | 34 | .terminal { 35 | overflow: hidden; 36 | border-radius: 3px; 37 | padding: 4px 1px; /* XXX change the session.js terminal_padding_size */ 38 | box-shadow: 0 1px rgba(255, 255, 255, 0.1); 39 | } 40 | 41 | .ssh-cstr { 42 | margin-top: 1em; 43 | font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; 44 | font-size: 12px; 45 | line-height: 14px; 46 | display: block; 47 | text-align: center; 48 | } 49 | 50 | .faq { 51 | margin-top: 1em; 52 | text-align: center; 53 | color: #666; 54 | font-size: 10px; 55 | } 56 | -------------------------------------------------------------------------------- /assets/js/term/window.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Pane from "./pane" 3 | 4 | export default class Window extends React.Component { 5 | render() { 6 | const win_size = this.props.session.state.size 7 | 8 | const panes = this.props.panes.map(pane => { 9 | const [id, cols, rows, x, y] = pane 10 | 11 | const pane_style = {left: this.props.session.get_row_width(x), 12 | top: this.props.session.get_col_height(y)} 13 | const active = this.props.active && this.props.active_pane_id === id 14 | const class_name = active && this.props.panes.length > 1 ? 15 | "pane_container active" : "pane_container" 16 | 17 | return
    18 | 20 |
    21 | }) 22 | 23 | const style = {width: this.props.session.get_row_width(win_size[0]) + 24 | this.props.session.terminal_padding_size.width, 25 | height: this.props.session.get_col_height(win_size[1]) + 26 | this.props.session.terminal_padding_size.height} 27 | return
    {panes}
    28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/js/term/xterm_constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2019 The xterm.js authors. All rights reserved. 3 | * @license MIT 4 | */ 5 | 6 | export const DEFAULT_COLOR = 256; 7 | export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); 8 | 9 | export const CHAR_DATA_ATTR_INDEX = 0; 10 | export const CHAR_DATA_CHAR_INDEX = 1; 11 | export const CHAR_DATA_WIDTH_INDEX = 2; 12 | export const CHAR_DATA_CODE_INDEX = 3; 13 | 14 | /** 15 | * Null cell - a real empty cell (containing nothing). 16 | * Note that code should always be 0 for a null cell as 17 | * several test condition of the buffer line rely on this. 18 | */ 19 | export const NULL_CELL_CHAR = ''; 20 | export const NULL_CELL_WIDTH = 1; 21 | export const NULL_CELL_CODE = 0; 22 | 23 | /** 24 | * Whitespace cell. 25 | * This is meant as a replacement for empty cells when needed 26 | * during rendering lines to preserve correct aligment. 27 | */ 28 | export const WHITESPACE_CELL_CHAR = ' '; 29 | export const WHITESPACE_CELL_WIDTH = 1; 30 | export const WHITESPACE_CELL_CODE = 32; 31 | 32 | /** 33 | * Bitmasks for accessing data in `content`. 34 | */ 35 | export enum Content { 36 | /** 37 | * bit 1..21 codepoint, max allowed in UTF32 is 0x10FFFF (21 bits taken) 38 | * read: `codepoint = content & Content.codepointMask;` 39 | * write: `content |= codepoint & Content.codepointMask;` 40 | * shortcut if precondition `codepoint <= 0x10FFFF` is met: 41 | * `content |= codepoint;` 42 | */ 43 | CODEPOINT_MASK = 0x1FFFFF, 44 | 45 | /** 46 | * bit 22 flag indication whether a cell contains combined content 47 | * read: `isCombined = content & Content.isCombined;` 48 | * set: `content |= Content.isCombined;` 49 | * clear: `content &= ~Content.isCombined;` 50 | */ 51 | IS_COMBINED_MASK = 0x200000, // 1 << 21 52 | 53 | /** 54 | * bit 1..22 mask to check whether a cell contains any string data 55 | * we need to check for codepoint and isCombined bits to see 56 | * whether a cell contains anything 57 | * read: `isEmpty = !(content & Content.hasContent)` 58 | */ 59 | HAS_CONTENT_MASK = 0x3FFFFF, 60 | 61 | /** 62 | * bit 23..24 wcwidth value of cell, takes 2 bits (ranges from 0..2) 63 | * read: `width = (content & Content.widthMask) >> Content.widthShift;` 64 | * `hasWidth = content & Content.widthMask;` 65 | * as long as wcwidth is highest value in `content`: 66 | * `width = content >> Content.widthShift;` 67 | * write: `content |= (width << Content.widthShift) & Content.widthMask;` 68 | * shortcut if precondition `0 <= width <= 3` is met: 69 | * `content |= width << Content.widthShift;` 70 | */ 71 | WIDTH_MASK = 0xC00000, // 3 << 22 72 | WIDTH_SHIFT = 22 73 | } 74 | 75 | export enum Attributes { 76 | /** 77 | * bit 1..8 blue in RGB, color in P256 and P16 78 | */ 79 | BLUE_MASK = 0xFF, 80 | BLUE_SHIFT = 0, 81 | PCOLOR_MASK = 0xFF, 82 | PCOLOR_SHIFT = 0, 83 | 84 | /** 85 | * bit 9..16 green in RGB 86 | */ 87 | GREEN_MASK = 0xFF00, 88 | GREEN_SHIFT = 8, 89 | 90 | /** 91 | * bit 17..24 red in RGB 92 | */ 93 | RED_MASK = 0xFF0000, 94 | RED_SHIFT = 16, 95 | 96 | /** 97 | * bit 25..26 color mode: DEFAULT (0) | P16 (1) | P256 (2) | RGB (3) 98 | */ 99 | CM_MASK = 0x3000000, 100 | CM_DEFAULT = 0, 101 | CM_P16 = 0x1000000, 102 | CM_P256 = 0x2000000, 103 | CM_RGB = 0x3000000, 104 | 105 | /** 106 | * bit 1..24 RGB room 107 | */ 108 | RGB_MASK = 0xFFFFFF 109 | } 110 | 111 | export enum FgFlags { 112 | /** 113 | * bit 27..31 (32th bit unused) 114 | */ 115 | INVERSE = 0x4000000, 116 | BOLD = 0x8000000, 117 | UNDERLINE = 0x10000000, 118 | BLINK = 0x20000000, 119 | INVISIBLE = 0x40000000 120 | } 121 | 122 | export enum BgFlags { 123 | /** 124 | * bit 27..32 (upper 4 unused) 125 | */ 126 | ITALIC = 0x4000000, 127 | DIM = 0x8000000 128 | } 129 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "jquery": "^3.4.1", 10 | "msgpack-js-browser": "git://github.com/creationix/msgpack-js-browser.git", 11 | "phoenix": "file:../deps/phoenix", 12 | "phoenix_html": "file:../deps/phoenix_html", 13 | "react": "^16.12.0", 14 | "react-dom": "^16.12.0", 15 | "xterm": "^4.2.0-vscode1" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.0.0", 19 | "@babel/plugin-proposal-class-properties": "^7.2.3", 20 | "@babel/plugin-proposal-object-rest-spread": "^7.2.0", 21 | "@babel/preset-env": "^7.0.0", 22 | "@babel/preset-react": "^7.0.0", 23 | "@babel/preset-typescript": "^7.7.4", 24 | "babel-loader": "^8.0.0", 25 | "copy-webpack-plugin": "^4.5.0", 26 | "css-loader": "^2.1.1", 27 | "mini-css-extract-plugin": "^0.4.0", 28 | "optimize-css-assets-webpack-plugin": "^5.0.1", 29 | "terser-webpack-plugin": "^1.1.0", 30 | "webpack": "4.4.0", 31 | "webpack-cli": "^3.3.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #151515; 3 | /* background-image: url('/img/rebel.png'); */ 4 | color: #bbb; 5 | font-size: 16px; 6 | } 7 | 8 | ::-webkit-selection { color: black; background: rgba(178, 104, 24, 0.99); } 9 | ::-moz-selection { color: black; background: rgba(178, 104, 24, 0.99); } 10 | ::selection { color: black; background: rgba(178, 104, 24, 0.99); } 11 | 12 | h1, h2, h3, h4, h5, h6 { 13 | color: #bbb; 14 | } 15 | 16 | .nav-tabs { 17 | font-size: 15px; 18 | min-inline-size: max-content; 19 | } 20 | 21 | .nav-tabs > li > a { 22 | padding: 7px; 23 | } 24 | 25 | 26 | li, p { 27 | line-height: 1.4em; 28 | } 29 | 30 | label, input, button, select, textarea { 31 | font-size: 1em; 32 | line-height: 1.4em; 33 | } 34 | 35 | 36 | .hero-unit { 37 | margin-top: 50px; 38 | text-align: center; 39 | background-color: rgba(0, 0, 0, 0.3); 40 | 41 | -webkit-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 42 | -moz-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 43 | box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 44 | } 45 | 46 | .hero-unit h1 { color: #fff; text-shadow: 0px 0px 5px rgba(255, 255, 255, 0.4); } 47 | .hero-unit h2 { color: #ccc; } 48 | .hero-unit p { text-align: center; } 49 | 50 | hr { 51 | border-bottom: 1px solid #3b3b3b; 52 | border-top: 0; 53 | margin-top: 35px; 54 | 55 | margin-top: 1em; 56 | margin-bottom: 2em; 57 | } 58 | 59 | code, pre { 60 | font-size: 13px; 61 | font-family: "Bitstream Vera Sans Mono", "Courier New", Courier, monospace; 62 | line-height: 1.2em; 63 | overflow: auto; 64 | background-color: rgba(0, 0, 0, 0.1); 65 | color: #bbb; 66 | -webkit-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 67 | -moz-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 68 | box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 69 | border: 0; 70 | } 71 | 72 | .fig { 73 | -webkit-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 74 | -moz-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 75 | box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 76 | 77 | -webkit-border-radius: 5px; 78 | -moz-border-radius: 5px; 79 | border-radius: 5px; 80 | 81 | background-color: rgba(0, 0, 0, 0.1); 82 | padding: 10px; 83 | margin-bottom: 1em; 84 | } 85 | 86 | p.fig-label { 87 | text-align: center; 88 | span { font-weight: bold; } 89 | margin-bottom: 2em; 90 | } 91 | 92 | .video { 93 | margin-top: 10px; 94 | -webkit-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 95 | -moz-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 96 | box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 97 | } 98 | 99 | .video.linux { 100 | background-image: url('/img/video_linux_first_frame.png'); 101 | height: 285px; 102 | width: 413px; 103 | margin-left: 30px; 104 | } 105 | 106 | .video.macos { 107 | background-image: url('/img/video_macos_first_frame.png'); 108 | height: 285px; 109 | width: 409px; 110 | float: right; 111 | margin-right: 30px; 112 | } 113 | 114 | .steps { 115 | text-align: center; 116 | margin: 0 auto; 117 | margin-top: 20px; 118 | width: 720px; 119 | height: 50px; 120 | } 121 | 122 | .steps h3 { 123 | width: 240px; 124 | float: left; 125 | display: none; 126 | margin-top: 0; 127 | margin-bottom: -40px; 128 | height: 50px; 129 | } 130 | 131 | .nav-tabs { 132 | border-bottom: 1px solid #3b3b3b; 133 | } 134 | 135 | .nav-tabs > li { 136 | margin-bottom: -1px; 137 | } 138 | 139 | .nav-tabs > li > a:hover, 140 | .nav-tabs > li > a:focus { 141 | border-color: #3b3b3b; 142 | } 143 | 144 | .nav-tabs > .active > a, 145 | .nav-tabs > .active > a:hover, 146 | .nav-tabs > .active > a:focus { 147 | color: #aaa; 148 | cursor: default; 149 | background-color: rgba(0, 0, 0, 0.3); 150 | border: 1px solid #3b3b3b; 151 | border-bottom-color: transparent; 152 | } 153 | 154 | .nav > li > a:hover, 155 | .nav > li > a:focus { 156 | text-decoration: none; 157 | color: #0088cc; 158 | background-color: rgba(0, 0, 0, 0.3); 159 | } 160 | 161 | .footer { 162 | width: 100%; 163 | height: 140px; 164 | margin-top: 40px; 165 | padding-top: 30px; 166 | background-color: rgba(0, 0, 0, 0.5); 167 | } 168 | .footer .digitalocean p { 169 | text-align: center; 170 | } 171 | 172 | .social-buttons { 173 | margin-top: 10px; 174 | margin-bottom: -30px; 175 | } 176 | 177 | .social-buttons .twitter { 178 | display: inline; 179 | margin-top: 8px; 180 | margin-right: 17px; 181 | } 182 | 183 | .social-buttons .github { 184 | display: inline; 185 | } 186 | 187 | .alert-success { 188 | color: #fff; 189 | background-color: #00ac7c; 190 | border-color: #00ac7c; 191 | text-shadow: none; 192 | } 193 | 194 | .user-registration input { 195 | color: #333; 196 | background-color: #d6d6d6; 197 | } 198 | 199 | .user-registration .help-block { 200 | color: #d88; 201 | } 202 | 203 | .user-registration button { 204 | margin-top: 0.5em; 205 | } 206 | 207 | .warning { 208 | color: #d88; 209 | } 210 | 211 | .app-container p.loading { 212 | margin: auto; 213 | margin-top: 100px; 214 | width: 200px; 215 | text-align: center; 216 | } 217 | -------------------------------------------------------------------------------- /assets/static/img/1_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/1_step.png -------------------------------------------------------------------------------- /assets/static/img/2_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/2_step.png -------------------------------------------------------------------------------- /assets/static/img/3_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/3_step.png -------------------------------------------------------------------------------- /assets/static/img/digitalocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/digitalocean.png -------------------------------------------------------------------------------- /assets/static/img/fork-me-on-github-right-orange@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/fork-me-on-github-right-orange@2x.png -------------------------------------------------------------------------------- /assets/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2 -------------------------------------------------------------------------------- /assets/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf -------------------------------------------------------------------------------- /assets/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot -------------------------------------------------------------------------------- /assets/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff -------------------------------------------------------------------------------- /assets/static/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/icon.png -------------------------------------------------------------------------------- /assets/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/logo.png -------------------------------------------------------------------------------- /assets/static/img/mac_notr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/mac_notr.png -------------------------------------------------------------------------------- /assets/static/img/mac_tr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/mac_tr.png -------------------------------------------------------------------------------- /assets/static/img/rebel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/rebel.png -------------------------------------------------------------------------------- /assets/static/img/tmate.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 23 | 25 | image/svg+xml 26 | 28 | logomark + wordmark 29 | 30 | 31 | 32 | 57 | 58 | logomark + wordmark 60 | Created with Sketch. 62 | 64 | 67 | 76 | 82 | 88 | 93 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /assets/static/img/video_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_linux.png -------------------------------------------------------------------------------- /assets/static/img/video_linux_first_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_linux_first_frame.png -------------------------------------------------------------------------------- /assets/static/img/video_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_mac.png -------------------------------------------------------------------------------- /assets/static/img/video_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_macos.png -------------------------------------------------------------------------------- /assets/static/img/video_macos_first_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_macos_first_frame.png -------------------------------------------------------------------------------- /assets/static/js/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | * If you are curious, here is how I generated the videos: 3 | * ffmpeg -an -f x11grab -r 30 -s 460x285 -i :0.0+1920,0 -pix_fmt yuv444p -vcodec libx264 -x264opts crf=0 out.mp4 4 | * mplayer -fps 3000 -vo png out.mp4 5 | * montage *.png -tile x14 -geometry '460x285' out.png 6 | * pngcrush out.png video.png 7 | * 8 | * Safari and Firefox don't like very wide pngs, so we chunk the frames in 9 | * rows. Sadly it makes the png bigger. 10 | */ 11 | 12 | $(function() { 13 | var video_linux = $('.video.linux'); 14 | var video_macos = $('.video.macos'); 15 | var current_frame = 0; 16 | var timer; 17 | 18 | var nextFrameFor = function(video, rows, total_frames, offset) { 19 | var frame = current_frame - offset; 20 | if (frame < 0) 21 | return; 22 | 23 | var frames_per_row = Math.ceil(total_frames / rows); 24 | var x = video.width() * (frame % frames_per_row); 25 | var y = video.height() * Math.floor(frame / frames_per_row); 26 | var position = "-" + x + "px -" + y + "px"; 27 | video.css('background-position', position); 28 | } 29 | 30 | var nextFrame = function() { 31 | if (current_frame == 90) 32 | $('.steps .launch').fadeIn(900); 33 | if (current_frame == 250) 34 | $('.steps .share').fadeIn(900); 35 | if (current_frame == 410) 36 | $('.steps .pair').fadeIn(900); 37 | 38 | nextFrameFor(video_linux, 12, 465, 0); 39 | nextFrameFor(video_macos, 6, 231, 234); 40 | 41 | current_frame += 1; 42 | if (current_frame >= 465) 43 | clearTimeout(timer); 44 | }; 45 | 46 | var startPlayback = function() { 47 | timer = setInterval(nextFrame, 33); 48 | }; 49 | 50 | $("").load(function() { 51 | $("").load(function() { 52 | video_linux.css('background-image', "url('/img/video_linux.png')"); 53 | video_macos.css('background-image', "url('/img/video_macos.png')"); 54 | setTimeout(startPlayback, 2000); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new TerserPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}) 13 | ] 14 | }, 15 | entry: { 16 | './js/app.js': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 17 | }, 18 | output: { 19 | filename: 'app.js', 20 | path: path.resolve(__dirname, '../priv/static/js') 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(js|ts)$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | }, 31 | { 32 | test: /\.css$/, 33 | use: [MiniCssExtractPlugin.loader, 'css-loader'] 34 | } 35 | ] 36 | }, 37 | plugins: [ 38 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 39 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 40 | ] 41 | }); 42 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | config :tmate, 11 | ecto_repos: [Tmate.Repo] 12 | 13 | # Configures the endpoint 14 | config :tmate, TmateWeb.Endpoint, 15 | url: [host: "localhost"], 16 | secret_key_base: "DKaayGHtzQJFsdBK4HfamYXCdSd4aOLV7T5+XY+9XzIKuoUWYwMhJH+/U2N/7zkf", 17 | render_errors: [view: TmateWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub: [name: Tmate.PubSub, adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | # Use Jason for JSON parsing in Phoenix 26 | config :phoenix, :json_library, Jason 27 | 28 | 29 | config :tmate, Tmate.Monitoring.Endpoint, 30 | enabled: true, 31 | cowboy_opts: [port: 9100] 32 | 33 | config :tmate, Tmate.MonitoringCollector, 34 | metrics_enabled: true 35 | 36 | config :prometheus, Tmate.PlugExporter, 37 | path: "/metrics", 38 | format: :auto, 39 | registry: :default, 40 | auth: false 41 | 42 | config :tmate, Tmate.Mailer, 43 | from: System.get_env("EMAIL_FROM", "tmate ") 44 | 45 | # Import environment specific config. This must remain at the bottom 46 | # of this file so it overrides the configuration defined above. 47 | import_config "#{Mix.env()}.exs" 48 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :tmate, Tmate.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "tmate_dev", 8 | hostname: "postgres", 9 | show_sensitive_data_on_connection_error: true, 10 | pool_size: 10 11 | 12 | # For development, we disable any cache and enable 13 | # debugging and code reloading. 14 | # 15 | # The watchers configuration can be used to run external 16 | # watchers to your application. For example, we use it 17 | # with webpack to recompile .js and .css sources. 18 | config :tmate, TmateWeb.Endpoint, 19 | http: [port: 4000], 20 | debug_errors: true, 21 | code_reloader: true, 22 | check_origin: false, 23 | watchers: [ 24 | node: [ 25 | "node_modules/webpack/bin/webpack.js", 26 | "--mode", 27 | "development", 28 | "--watch-stdin", 29 | cd: Path.expand("../assets", __DIR__) 30 | ] 31 | ] 32 | 33 | # Watch static and templates for browser reloading. 34 | config :tmate, TmateWeb.Endpoint, 35 | live_reload: [ 36 | patterns: [ 37 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 38 | ~r"priv/gettext/.*(po)$", 39 | ~r"lib/tmate_web/{live,views}/.*(ex)$", 40 | ~r"lib/tmate_web/templates/.*(eex)$" 41 | ] 42 | ] 43 | 44 | # Do not include metadata nor timestamps in development logs 45 | config :logger, :console, format: "[$level] $message\n" 46 | 47 | # Set a higher stacktrace during development. Avoid configuring such 48 | # in production as building large stacktraces may be expensive. 49 | config :phoenix, :stacktrace_depth, 20 50 | 51 | # Initialize plugs at runtime for faster development compilation 52 | config :phoenix, :plug_init_mode, :runtime 53 | 54 | config :tmate, :master, 55 | internal_api: [auth_token: "internal_api_auth_token"] 56 | 57 | config :tmate, Tmate.Scheduler, 58 | enabled: true, 59 | jobs: [ 60 | # Every 5 minutes 61 | {"*/5 * * * *", {Tmate.SessionCleaner, :check_for_disconnected_sessions, []}}, 62 | {"*/5 * * * *", {Tmate.SessionCleaner, :prune_sessions, []}}, 63 | ] 64 | 65 | config :tmate, Tmate.Mailer, 66 | adapter: Bamboo.LocalAdapter 67 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :tmate, TmateWeb.Endpoint, 13 | http: [port: System.get_env("MASTER_HTTP_PORT", "4000") |> String.to_integer(), 14 | compress: true, protocol_options: [ 15 | proxy_header: System.get_env("USE_PROXY_PROTOCOL") == "1"]], 16 | url: System.get_env("MASTER_BASE_URL", "") |> URI.parse() |> Map.to_list(), 17 | secret_key_base: System.get_env("SECRET_KEY_BASE"), 18 | cache_static_manifest: "priv/static/cache_manifest.json" 19 | # XXX If SSL options are needed. See tmate-websocket for example 20 | config :tmate, TmateWeb.Endpoint, server: true 21 | 22 | # Do not print debug messages in production 23 | config :logger, level: :info 24 | 25 | #database_url = 26 | # System.get_env("DATABASE_URL") || 27 | # raise """ 28 | # environment variable DATABASE_URL is missing. 29 | # For example: ecto://USER:PASS@HOST/DATABASE 30 | # """ 31 | # 32 | #config :tmate, Tmate.Repo, 33 | # # ssl: true, 34 | # url: database_url, 35 | # pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") 36 | 37 | pg = URI.parse(System.get_env("PG_URI", "pg://user:pass@host:5432/db")) 38 | config :tmate, Tmate.Repo, 39 | adapter: Ecto.Adapters.Postgres, 40 | timeout: 60_000, 41 | username: pg.userinfo |> String.split(":") |> Enum.at(0), 42 | password: pg.userinfo |> String.split(":") |> Enum.at(1), 43 | database: pg.path |> String.split("/") |> Enum.at(1), 44 | port: pg.port, 45 | hostname: pg.host, 46 | pool_size: System.get_env("PG_POOLSIZE", "20") |> String.to_integer(), 47 | ssl: System.get_env("PG_SSL_CA_CERT") != nil, 48 | ssl_opts: [cacertfile: System.get_env("PG_SSL_CA_CERT")], 49 | # x4 all queue_target and queue_target settings, 50 | # in the hope to reduce the number of DBConnection Errors 51 | queue_target: 200, 52 | queue_interval: 4000 53 | 54 | 55 | config :tmate, Tmate.Monitoring.Endpoint, 56 | port: System.get_env("MASTER_METRICS_PORT", "9100") |> String.to_integer() 57 | 58 | config :tmate, :master, 59 | internal_api: [auth_token: System.get_env("INTERNAL_API_AUTH_TOKEN")] 60 | 61 | config :tzdata, :autoupdate, :disabled 62 | 63 | # This requires a statefulset setup 64 | machine_index = System.get_env("HOSTNAME", "master-0") 65 | |> String.split("-") |> Enum.at(-1) |> String.to_integer() 66 | 67 | config :tmate, Tmate.MonitoringCollector, 68 | metrics_enabled: machine_index == 0 69 | 70 | config :tmate, Tmate.Scheduler, 71 | enabled: machine_index == 0, 72 | jobs: [ 73 | # every minute 74 | {"*/1 * * * *", {Tmate.SessionCleaner, :check_for_disconnected_sessions, []}}, 75 | {"*/1 * * * *", {Tmate.SessionCleaner, :prune_sessions, []}}, 76 | ] 77 | 78 | email_adapter_opts = case (System.get_env("EMAIL_ADAPTER", "mailgun") |> String.downcase) do 79 | "smtp" -> [ 80 | adapter: Bamboo.SMTPAdapter, 81 | server: System.get_env("SMTP_HOST"), 82 | port: System.get_env("SMTP_PORT"), 83 | hostname: System.get_env("SMTP_DOMAIN")] 84 | "mailgun" -> [ 85 | adapter: Bamboo.MailgunAdapter, 86 | api_key: System.get_env("MAILGUN_API_KEY"), 87 | domain: System.get_env("MAILGUN_DOMAIN")] 88 | other -> raise "Unknown email handler: '#{other}'" 89 | end 90 | 91 | config :tmate, Tmate.Mailer, 92 | email_adapter_opts 93 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # Configure your database 4 | config :tmate, Tmate.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "tmate_test", 8 | hostname: "localhost", 9 | pool: Ecto.Adapters.SQL.Sandbox 10 | 11 | # We don't run a server during test. If one is required, 12 | # you can enable the server option below. 13 | config :tmate, TmateWeb.Endpoint, 14 | http: [port: 4002], 15 | server: false 16 | 17 | # Print only warnings and errors during test 18 | config :logger, level: if System.get_env("DEBUG"), do: :debug, else: :warn 19 | 20 | config :phoenix, :stacktrace_depth, 20 21 | 22 | config :tmate, Tmate.Monitoring.Endpoint, 23 | enabled: false 24 | 25 | config :tmate, :master, 26 | internal_api: [auth_token: "internal_api_auth_token"] 27 | 28 | config :tmate, Tmate.Scheduler, 29 | enabled: false 30 | 31 | config :tmate, Tmate.Mailer, 32 | adapter: Bamboo.TestAdapter 33 | -------------------------------------------------------------------------------- /lib/tmate.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate do 2 | @moduledoc """ 3 | Tmate keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/tmate/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | Tmate.Monitoring.setup() 10 | 11 | # List all child processes to be supervised 12 | children = [ 13 | # Start the Ecto repository 14 | Tmate.Repo, 15 | # Start the endpoint when the application starts 16 | TmateWeb.Endpoint 17 | # Starts a worker by calling: Tmate.Worker.start_link(arg) 18 | # {Tmate.Worker, arg}, 19 | ] 20 | 21 | {:ok, monitoring_options} = Application.fetch_env(:tmate, Tmate.Monitoring.Endpoint) 22 | {:ok, scheduler_options} = Application.fetch_env(:tmate, Tmate.Scheduler) 23 | 24 | children = cond do 25 | monitoring_options[:enabled] -> children ++ [ 26 | Plug.Cowboy.child_spec(scheme: :http, plug: Tmate.Monitoring.Endpoint, 27 | options: monitoring_options[:cowboy_opts]) 28 | ] 29 | true -> children 30 | end 31 | 32 | children = cond do 33 | scheduler_options[:enabled] -> children ++ [Tmate.Scheduler] 34 | true -> children 35 | end 36 | 37 | # See https://hexdocs.pm/elixir/Supervisor.html 38 | # for other strategies and supported options 39 | opts = [strategy: :one_for_one, name: Tmate.Supervisor] 40 | Supervisor.start_link(children, opts) 41 | end 42 | 43 | # Tell Phoenix to update the endpoint configuration 44 | # whenever the application is updated. 45 | def config_change(changed, _new, removed) do 46 | TmateWeb.Endpoint.config_change(changed, removed) 47 | :ok 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/tmate/event_projections.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.EventProjections do 2 | require Logger 3 | 4 | @handlers [ 5 | &__MODULE__.Session.handle_event/4, 6 | &__MODULE__.User.handle_event/4, 7 | ] 8 | 9 | def handle_event(event_type, id, timestamp, params) do 10 | @handlers |> Enum.each(fn handler -> 11 | invoke_handler(handler, event_type, id, timestamp, params) 12 | end) 13 | end 14 | 15 | defp invoke_handler(func, event_type, id, timestamp, params) do 16 | # TODO raise after invoking all handlers 17 | try do 18 | func.(event_type, id, timestamp, params) 19 | catch 20 | kind, reason -> 21 | evt = %{event_type: event_type, entity_id: id, timestamp: timestamp, params: params} 22 | Logger.error("Exception occured while handling event: #{inspect(evt)}") 23 | :erlang.raise(kind, reason, __STACKTRACE__) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/tmate/event_projections/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.EventProjections.Session do 2 | require Logger 3 | 4 | alias Tmate.Session 5 | alias Tmate.Client 6 | alias Tmate.Repo 7 | alias Tmate.Util.EctoHelpers 8 | 9 | import Ecto.Query 10 | 11 | # handle_event() is run withing a Repo.transaction() 12 | 13 | # Events are ordered for a given generation. 14 | # This is useful when the tmate client reconnects on an other server. 15 | # Consider this timeline: 16 | # * client connects to server A 17 | # * server A sends the session_register event. (generation=1) 18 | # * client reconnects to server B 19 | # * server B sends the session_register,reconnected. (generation=2) 20 | # * server A sends a disconnection event. (generation=1) 21 | # We need to throw away the last disconnect event. Thankfully, 22 | # the events have generation numbers. Generations are incremented 23 | # upon reconnections. 24 | # If we receive an even with a younger generation that we have seen, we must 25 | # throw it away. 26 | 27 | defp when_generation_fresh(%Ecto.Changeset{ 28 | changes: %{generation: event_generation}, 29 | data: %Session{id: id, generation: session_generation} 30 | }=changeset, event, func) do 31 | effective_gen = fn gen -> if gen == nil, do: 1, else: gen end 32 | 33 | if effective_gen.(session_generation) <= effective_gen.(event_generation) do 34 | func.(changeset) 35 | else 36 | Logger.warn("Discarding event=#{event} for session id=#{id} " <> 37 | "session_generation=#{inspect(session_generation)}, " <> 38 | "event_generation=#{inspect(event_generation)}") 39 | end 40 | end 41 | 42 | # This matches when there's no generation changes. Meaning the generations stay 43 | # the same. 44 | defp when_generation_fresh(changeset, _event, func) do 45 | func.(changeset) 46 | end 47 | 48 | defp close_session_clients(session_id) do 49 | from(c in Client, where: c.session_id == ^session_id) |> Repo.delete_all() 50 | end 51 | 52 | def handle_event(:session_register=event, id, timestamp, 53 | %{generation: generation, ip_address: ip_address, 54 | ws_url_fmt: ws_url_fmt, ssh_cmd_fmt: ssh_cmd_fmt, 55 | stoken: stoken, stoken_ro: stoken_ro, 56 | reconnected: reconnected}) do 57 | if reconnected do 58 | Logger.info("Reconnected session id=#{id}") 59 | else 60 | Logger.info("New session id=#{id}") 61 | end 62 | 63 | session_params = %{id: id, host_last_ip: ip_address, 64 | ws_url_fmt: ws_url_fmt, ssh_cmd_fmt: ssh_cmd_fmt, 65 | stoken: stoken, stoken_ro: stoken_ro, created_at: timestamp, 66 | generation: generation, disconnected_at: nil, closed: false} 67 | Session.changeset(%Session{}, session_params) 68 | |> EctoHelpers.get_or_insert! 69 | |> Session.changeset(session_params) 70 | |> when_generation_fresh(event, fn changeset -> 71 | Repo.update(changeset) 72 | close_session_clients(id) 73 | end) 74 | end 75 | 76 | def handle_event(:session_close=event, id, timestamp, %{generation: generation}) do 77 | Logger.info("Closed session id=#{id}") 78 | 79 | if (session = Repo.get(Session, id)) do 80 | session 81 | |> Session.changeset(%{generation: generation, disconnected_at: timestamp, closed: true}) 82 | |> when_generation_fresh(event, fn changeset -> 83 | Repo.update(changeset) 84 | close_session_clients(id) 85 | end) 86 | end 87 | end 88 | 89 | def handle_event(:session_disconnect=event, id, timestamp, %{generation: generation}) do 90 | Logger.info("Disconnected session id=#{id}") 91 | 92 | if (session = Repo.get(Session, id)) do 93 | session 94 | |> Session.changeset(%{generation: generation, disconnected_at: timestamp}) 95 | |> when_generation_fresh(event, fn changeset -> 96 | Repo.update(changeset) 97 | close_session_clients(id) 98 | end) 99 | end 100 | end 101 | 102 | def handle_event(:session_join=event, sid, timestamp, %{generation: generation, 103 | id: cid, ip_address: ip_address, type: type, readonly: readonly}) do 104 | Logger.info("Client joined session sid=#{sid}, cid=#{cid}" <> 105 | ", type=#{type}, readonly=#{readonly}") 106 | 107 | if (session = Repo.get(Session, sid)) do 108 | session 109 | |> Session.changeset(%{generation: generation}) 110 | |> when_generation_fresh(event, fn changeset -> 111 | Repo.update(changeset) 112 | 113 | client_params = %{id: cid, session_id: sid, 114 | ip_address: ip_address, joined_at: timestamp, readonly: readonly} 115 | Client.changeset(%Client{}, client_params) 116 | |> EctoHelpers.get_or_insert! 117 | end) 118 | end 119 | end 120 | 121 | def handle_event(:session_left=event, sid, _timestamp, %{generation: generation, id: cid}) do 122 | Logger.info("Client left session sid=#{sid}, cid=#{cid}") 123 | 124 | if (session = Repo.get(Session, sid)) do 125 | session 126 | |> Session.changeset(%{generation: generation}) 127 | |> when_generation_fresh(event, fn changeset -> 128 | Repo.update(changeset) 129 | 130 | # The session_left event can be duplicated. So we allow the record to be absent. 131 | %Client{id: cid} 132 | |> Repo.delete(stale_error_field: :_stale_) 133 | end) 134 | end 135 | end 136 | 137 | def handle_event(_, _, _, _) do 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/tmate/event_projections/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.EventProjections.User do 2 | alias Tmate.Util.EctoHelpers 3 | alias Tmate.User 4 | alias Tmate.Repo 5 | import Ecto.Changeset 6 | require Logger 7 | 8 | def handle_event(:user_create, user_id, timestamp, params) do 9 | %User{id: user_id} 10 | |> change(params) 11 | |> put_change(:created_at, timestamp) 12 | |> User.changeset() 13 | |> EctoHelpers.get_or_insert! 14 | end 15 | 16 | def handle_event(:expire_user, user_id, _timestamp, _params) do 17 | if (user = Repo.get(User, user_id)) do 18 | Logger.info("Expire #{User.repr(user)}") 19 | end 20 | %User{id: user_id} |> Repo.delete(stale_error_field: :_stale_) 21 | end 22 | 23 | 24 | def handle_event(:email_api_key, user_id, _timestamp, _params) do 25 | user = Repo.get!(User, user_id) 26 | Logger.info("Emailing api_key to #{User.repr(user)}") 27 | Tmate.Email.api_key_email(user) 28 | |> Tmate.Mailer.deliver_now() 29 | end 30 | 31 | def handle_event(:session_register, _sid, timestamp, 32 | %{stoken: stoken, stoken_ro: stoken_ro}) do 33 | get_username_from = fn token -> 34 | case String.split(token, "/") do 35 | [username, _session_name] -> username 36 | _ -> nil 37 | end 38 | end 39 | 40 | username = get_username_from.(stoken) || get_username_from.(stoken_ro) 41 | cond do 42 | username == nil -> nil 43 | user = Repo.get_by(User, username: username) -> 44 | user 45 | |> User.seen(timestamp) 46 | |> Repo.update() 47 | true -> 48 | Logger.warn("Username not found: #{username}") 49 | end 50 | end 51 | 52 | def handle_event(_, _, _, _) do 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/tmate/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Mailer do 2 | use Bamboo.Mailer, otp_app: :tmate 3 | end 4 | -------------------------------------------------------------------------------- /lib/tmate/models/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Client do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:id, :binary_id, autogenerate: false} 6 | 7 | schema "clients" do 8 | belongs_to :session, Tmate.Session, type: :binary_id, references: :id 9 | field :ip_address, :string 10 | field :joined_at, :utc_datetime 11 | field :readonly, :boolean 12 | end 13 | 14 | def changeset(model, params \\ %{}) do 15 | model 16 | |> change(params) 17 | |> unique_constraint(:id, name: :clients_pkey) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/tmate/models/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Event do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | alias Tmate.Event 6 | import Ecto.Query 7 | alias Tmate.Repo 8 | 9 | require Logger 10 | 11 | schema "events" do 12 | field :type, :string 13 | field :entity_id, Ecto.UUID 14 | field :timestamp, :utc_datetime 15 | field :params, :map 16 | end 17 | 18 | def changeset(model, params \\ %{}) do 19 | model 20 | |> change(params) 21 | end 22 | 23 | def emit!(event_type, entity_id, timestamp, params) do 24 | timestamp = DateTime.truncate(timestamp, :second) 25 | event_params = %{type: Atom.to_string(event_type), entity_id: entity_id, 26 | timestamp: timestamp, params: params} 27 | Repo.transaction fn -> 28 | Event.changeset(%Event{}, event_params) |> Repo.insert! 29 | Tmate.EventProjections.handle_event(event_type, entity_id, timestamp, params) 30 | end 31 | end 32 | 33 | def emit!(event_type, entity_id, timestamp, generation, params) do 34 | # generation == 1 will be stored as nil (easier to migrate, and maybe it's 35 | # more efficient storage wise). 36 | generation = if generation == 1, do: nil, else: generation 37 | 38 | # Turns out, trying to order event by looking at the Event table was a 39 | # mistake: performance were terrible. 40 | # generations are only useful for sessions, and we'll deal with that in the 41 | # session handlers. 42 | emit!(event_type, entity_id, timestamp, Map.merge(params, %{generation: generation})) 43 | end 44 | 45 | def emit!(event_type, entity_id, params) do 46 | now = DateTime.utc_now 47 | emit!(event_type, entity_id, now, params) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/tmate/models/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Session do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key {:id, :binary_id, autogenerate: false} 6 | 7 | schema "sessions" do 8 | field :host_last_ip, :string 9 | field :ws_url_fmt, :string 10 | field :ssh_cmd_fmt, :string 11 | field :stoken, :string 12 | field :stoken_ro, :string 13 | field :created_at, :utc_datetime 14 | field :disconnected_at, :utc_datetime 15 | field :closed, :boolean 16 | field :generation, :integer 17 | has_many :clients, Tmate.Client 18 | end 19 | 20 | def changeset(model, params \\ %{}) do 21 | model 22 | |> change(params) 23 | |> unique_constraint(:id, name: :sessions_pkey) 24 | end 25 | 26 | def wsapi_base_url(ws_url_fmt) do 27 | # e.g., wss://lon1.tmate.io:33/ws/session/%s 28 | case URI.parse(ws_url_fmt).authority do 29 | # dev mode: hardcoding, not great, but it's fine for now 30 | "localhost:4001" -> "http://session:4001/internal_api" 31 | host -> "https://#{host}/internal_api" 32 | end 33 | end 34 | 35 | def edge_srv_hostname(ssh_hostname) do 36 | # ssh -p2200 %s@ny3.tmate.io 37 | ssh_hostname 38 | |> String.split("@") |> Enum.at(1) 39 | |> String.split(".") |> Enum.at(0) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/tmate/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | import Ecto.Query 6 | alias Tmate.Repo 7 | alias Tmate.User 8 | 9 | require Logger 10 | 11 | @primary_key {:id, :binary_id, autogenerate: false} 12 | 13 | schema "users" do 14 | field :username, :string 15 | field :email, :string 16 | field :api_key, :string 17 | field :verified, :boolean 18 | field :created_at, :utc_datetime 19 | field :last_seen_at, :utc_datetime 20 | field :allow_mailing_list, :boolean 21 | end 22 | 23 | def get_by_api_key(api_key) do 24 | user = Repo.one(from u in User, where: u.api_key == ^api_key) 25 | if !user, do: :timer.sleep(:crypto.rand_uniform(50, 200)) 26 | user 27 | end 28 | 29 | def repr(user) do 30 | "user=#{user.username} (#{user.email})" 31 | end 32 | 33 | def seen(user, timestamp) do 34 | Logger.info("#{User.repr(user)} seen") 35 | 36 | user 37 | |> change(%{verified: true, last_seen_at: timestamp}) 38 | end 39 | 40 | def changeset(model, params \\ %{}) do 41 | model 42 | |> cast(params, [:username, :email, :allow_mailing_list]) 43 | |> validate_required(:username) 44 | |> validate_length(:username, min: 1, max: 40) 45 | |> validate_format(:username, ~r/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*$/, 46 | message: "Username may only contain alphanumeric characters or single hyphens" 47 | <> ", and cannot begin or end with a hyphen.") 48 | |> validate_required(:email) 49 | |> validate_format(:email, ~r/.@.*\.../, message: "Check your email") 50 | |> gen_api_key_if_empty() 51 | |> unique_constraint(:id, name: :users_pkey) 52 | |> unique_constraint(:username) 53 | |> unique_constraint(:email) 54 | |> unique_constraint(:api_key) 55 | end 56 | 57 | defmodule ApiKeyUtil do 58 | @api_key_letters "abcdefghjklmnopqrstuvwxyz" <> 59 | "ABCDEFGHJKLMNOPQRSTUVWXYZ" <> 60 | "0123456789" 61 | 62 | defp generate_random_char(chars, num_chars) do 63 | case :crypto.strong_rand_bytes(1) do 64 | <> when rand_int < num_chars -> String.at(chars, rand_int) 65 | _ -> generate_random_char(chars, num_chars) 66 | end 67 | end 68 | 69 | defp generate_random_string(chars, length) do 70 | rand_char = fn -> generate_random_char(chars, String.length(chars)) end 71 | Stream.repeatedly(rand_char) |> Enum.take(length) |> Enum.join() 72 | end 73 | 74 | def gen_api_key() do 75 | "tmk-#{generate_random_string(@api_key_letters, 26)}" 76 | end 77 | end 78 | 79 | defp gen_api_key_if_empty(changeset) do 80 | case get_change(changeset, :api_key) do 81 | nil -> put_change(changeset, :api_key, ApiKeyUtil.gen_api_key()) 82 | _ -> changeset 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/tmate/monitoring.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Monitoring do 2 | require Prometheus.Registry 3 | 4 | def setup do 5 | Tmate.Endpoint.PhoenixInstrumenter.setup() 6 | Tmate.PlugExporter.setup() 7 | Tmate.Repo.Instrumenter.setup2() 8 | end 9 | end 10 | 11 | defmodule Tmate.Endpoint.PhoenixInstrumenter do 12 | use Prometheus.PhoenixInstrumenter 13 | end 14 | 15 | defmodule Tmate.Repo.Instrumenter do 16 | use Prometheus.EctoInstrumenter 17 | 18 | def setup2() do 19 | setup() 20 | :ok = :telemetry.attach( 21 | "prometheus-ecto", 22 | [:tmate, :repo, :query], 23 | &__MODULE__.handle_event/4, 24 | %{} 25 | ) 26 | end 27 | end 28 | 29 | # Tmate metrics are implemented in Tmate.MonitoringCollector 30 | 31 | ### Exporter 32 | 33 | defmodule Tmate.PlugExporter do 34 | use Prometheus.PlugExporter 35 | end 36 | 37 | defmodule Tmate.Monitoring.Endpoint do 38 | use Plug.Router 39 | 40 | plug Tmate.PlugExporter 41 | 42 | plug :match 43 | plug :dispatch 44 | 45 | match _ do 46 | send_resp(conn, 404, "Oops!") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/tmate/monitoring_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.MonitoringCollector do 2 | use Prometheus.Collector 3 | 4 | alias Tmate.Repo 5 | alias Tmate.Session 6 | alias Tmate.User 7 | import Ecto.Query 8 | 9 | # Collectors are automatically registered by the prometheus module. 10 | 11 | @bucket_names %{nil => "never", 0 => "long time", 12 | 1 => "past year", 2 => "past month", 3 => "past week", 4 => "past day"} 13 | @bucket_days [ 365, 30, 7, 1] 14 | 15 | def collect_mf(_registry, callback) do 16 | {:ok, options} = Application.fetch_env(:tmate, Tmate.MonitoringCollector) 17 | if options[:metrics_enabled], do: collect_mf_stub(callback) 18 | :ok 19 | end 20 | 21 | defp collect_mf_stub(callback) do 22 | per_host_key_fn = & Enum.map(&1, fn {ssh_cmd_fmt, count} -> 23 | {[host: Session.edge_srv_hostname(ssh_cmd_fmt)], count} 24 | end) 25 | 26 | callback.(create_keyed_gauge( 27 | :tmate_num_sessions, "Number of sessions", 28 | from(s in Session, where: is_nil(s.disconnected_at), 29 | group_by: s.ssh_cmd_fmt, 30 | select: {s.ssh_cmd_fmt, count(s.id)}), 31 | per_host_key_fn)) 32 | 33 | callback.(create_keyed_gauge( 34 | :tmate_num_paired_sessions, "Number of paired sessions", 35 | from(s in Session, where: is_nil(s.disconnected_at), 36 | join: assoc(s, :clients), 37 | group_by: s.ssh_cmd_fmt, 38 | select: {s.ssh_cmd_fmt, count(s.id, :distinct)}), 39 | per_host_key_fn)) 40 | 41 | callback.(create_keyed_gauge( 42 | :tmate_num_users, "Number of users seen", 43 | from(u in User, 44 | select: %{bucket: fragment("width_bucket(?, ARRAY[?,?,?,?])", 45 | u.last_seen_at, 46 | # Quite terrible, we should do the quote 47 | # unquote danse to deal with this (fragment 48 | # and ago are macros that are capricious). 49 | ago(^(@bucket_days |> Enum.at(0)), "day"), 50 | ago(^(@bucket_days |> Enum.at(1)), "day"), 51 | ago(^(@bucket_days |> Enum.at(2)), "day"), 52 | ago(^(@bucket_days |> Enum.at(3)), "day")), 53 | count: count()}, 54 | group_by: 1), 55 | & Enum.map(&1, fn %{bucket: bucket_index, count: count} -> 56 | {[bucket: Map.get(@bucket_names, bucket_index)], count} 57 | end))) 58 | end 59 | 60 | defmodule KeyedGauge do 61 | def collect_metrics(_name, {query, key_fn}) do 62 | Repo.all(query) 63 | |> key_fn.() 64 | |> Prometheus.Model.gauge_metrics() 65 | end 66 | end 67 | 68 | defp create_keyed_gauge(name, help, query, key_fn) do 69 | Prometheus.Model.create_mf(name, help, :gauge, __MODULE__.KeyedGauge, {query, key_fn}) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/tmate/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo do 2 | use Ecto.Repo, 3 | otp_app: :tmate, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/tmate/scheduler.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Scheduler do 2 | use Quantum.Scheduler, 3 | otp_app: :tmate 4 | end 5 | -------------------------------------------------------------------------------- /lib/tmate/session_cleaner.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.SessionCleaner do 2 | require Logger 3 | 4 | alias Tmate.Repo 5 | alias Tmate.Session 6 | alias Tmate.Event 7 | import Ecto.Query 8 | 9 | def prune_sessions() do 10 | prune_sessions({1, "week"}) 11 | end 12 | 13 | def prune_sessions({timeout_value, timeout_unit}) do 14 | Logger.info("Pruning dead sessions older than #{timeout_value} #{timeout_unit}") 15 | 16 | {n_pruned, sids} = from(s in Session, 17 | where: s.disconnected_at < ago(^timeout_value, ^timeout_unit), 18 | select: s.id) 19 | |> Repo.delete_all 20 | 21 | if n_pruned != 0 do 22 | Logger.info("Pruned #{n_pruned} dead sessions: #{sids}") 23 | end 24 | 25 | :ok 26 | end 27 | 28 | def check_for_disconnected_sessions(wsapi_module \\ Tmate.WsApi) do 29 | Logger.info("Checking for disconnected sessions") 30 | from(s in Session, where: is_nil(s.disconnected_at), 31 | select: {s.id, s.generation, s.ws_url_fmt}) 32 | |> Repo.all 33 | |> Enum.group_by(fn {_id, _generation, ws_url_fmt} -> ws_url_fmt end, 34 | fn {id, generation, _ws_url_fmt} -> {id, generation} end) 35 | |> Enum.each(fn {ws_url_fmt, sessions} -> 36 | sid_generations = sessions |> Map.new 37 | base_url = Session.wsapi_base_url(ws_url_fmt) 38 | check_for_disconnected_sessions(wsapi_module, base_url, sid_generations) 39 | end) 40 | 41 | :ok 42 | end 43 | 44 | defp check_for_disconnected_sessions(wsapi_module, base_url, sid_generations) do 45 | # When a websocket serves goes down, it does not necessarily notify disconnections. 46 | # We'll emit these missings events here. 47 | 48 | # 1) we get the stale entries 49 | case sid_generations 50 | |> Map.keys 51 | |> wsapi_module.get_stale_sessions(base_url) do 52 | {:ok, stale_ids} -> 53 | stale_ids 54 | |> Enum.map(& {&1, sid_generations[&1]}) 55 | |> Enum.each(fn {sid, generation} -> 56 | # 2) emit the events for the stale entries 57 | Logger.warn("Stale session id=#{sid}") 58 | Event.emit!(:session_disconnect, sid, DateTime.utc_now, generation, %{}) 59 | end) 60 | {:error, _} -> 61 | nil # error is already logged 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/tmate/util/ecto_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Util.EctoHelpers do 2 | alias Tmate.Repo 3 | import Ecto.Query 4 | 5 | def get_or_insert(changeset, key, retry) when is_atom(key) do 6 | get_or_insert(changeset, [key], retry) 7 | end 8 | 9 | def get_or_insert(changeset, query_keys, retry) do 10 | params = Enum.map(query_keys, fn key -> 11 | {_, value} = Ecto.Changeset.fetch_field(changeset, key) 12 | {key, value} 13 | end) |> Enum.into(%{}) 14 | 15 | case Repo.get_by(changeset.data.__struct__, params) do 16 | nil -> 17 | case {Repo.insert(changeset), retry} do 18 | {{:ok, instance}, _} -> 19 | {:ok, :insert, instance} 20 | {{:error, %{constraints: [%{field: _, type: :unique}]}}, true} -> 21 | # TODO write test case 22 | get_or_insert(changeset, query_keys, false) 23 | {{:error, changeset}, _} -> 24 | {:error, changeset} 25 | end 26 | instance -> {:ok, :get, instance} 27 | end 28 | end 29 | 30 | def get_or_insert(changeset, query_keys) do 31 | get_or_insert(changeset, query_keys, true) 32 | end 33 | 34 | def get_or_insert(changeset) do 35 | get_or_insert(changeset, Keyword.keys(Ecto.primary_key(changeset.data))) 36 | end 37 | 38 | def get_or_insert!(changeset, query_keys) do 39 | case get_or_insert(changeset, query_keys) do 40 | {:ok, _, instance} -> instance 41 | {:error, changeset} -> 42 | raise Ecto.InvalidChangesetError, action: :insert, changeset: changeset 43 | end 44 | end 45 | 46 | def get_or_insert!(changeset) do 47 | get_or_insert!(changeset, Keyword.keys(Ecto.primary_key(changeset.data))) 48 | end 49 | 50 | def last(model) do 51 | Repo.one(from s in model, order_by: [desc: :id], limit: 1) 52 | end 53 | 54 | def validate_changeset(changeset) do 55 | # Little sad that we don't get all constraints validations done, but it's a 56 | # hassle to do otherwise. 57 | case Repo.transaction(fn -> 58 | result = changeset |> Repo.insert 59 | Repo.rollback({:insert_result, result}) 60 | end) do 61 | {:error, {:insert_result, {:ok, _}}} -> :ok 62 | {:error, {:insert_result, {:error, changeset}}} -> {:error, changeset} 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /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_remote_ip.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Util.PlugRemoteIp do 2 | require Logger 3 | @behaviour Plug 4 | 5 | # We use the PROXY protocol, but if we were to use headers, 6 | # we could use https://github.com/ajvondrak/remote_ip 7 | 8 | def init(opts) do 9 | opts 10 | end 11 | 12 | def call(conn, _opts) do 13 | conn 14 | |> set_proxied_remote_ip() 15 | |> log_remote_ip() 16 | end 17 | 18 | defp set_proxied_remote_ip(%{adapter: {_connection, 19 | %{proxy_header: %{src_address: src_address} 20 | =_proxy_header}=_req}}=conn) do 21 | %{conn | remote_ip: src_address} 22 | end 23 | 24 | defp set_proxied_remote_ip(conn) do 25 | conn 26 | end 27 | 28 | defp log_remote_ip(%{remote_ip: remote_ip}=conn) do 29 | ip = :inet_parse.ntoa(remote_ip) |> to_string 30 | Logger.metadata(remote_ip: ip) 31 | conn 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /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_token1(%{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 | # old format 27 | defp verify_auth_token2(%{body_params: %{"userdata" => token}}, opts) do 28 | Plug.Crypto.secure_compare(token, opts[:auth_token]) 29 | end 30 | defp verify_auth_token2(_conn, _opts), do: false 31 | 32 | defp verify_auth_token!(conn, opts) do 33 | if (!verify_auth_token1(conn, opts) && !verify_auth_token2(conn, opts)) do 34 | raise Error.Unauthorized 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/tmate/ws_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.WsApi 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 get_stale_sessions(session_ids, base_url) do 10 | case post(base_url <> "/get_stale_sessions", %{session_ids: session_ids}) do 11 | {:ok, body} -> {:ok, body["stale_ids"]} 12 | {:error, reason} -> {:error, reason} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tmate_web.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use TmateWeb, :controller 9 | use TmateWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: TmateWeb 23 | 24 | import Plug.Conn 25 | import TmateWeb.Gettext 26 | alias TmateWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/tmate_web/templates", 34 | namespace: TmateWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 38 | 39 | # Use all HTML functionality (forms, tags, etc) 40 | use Phoenix.HTML 41 | 42 | import TmateWeb.ErrorHelpers 43 | import TmateWeb.Gettext 44 | alias TmateWeb.Router.Helpers, as: Routes 45 | end 46 | end 47 | 48 | def router do 49 | quote do 50 | use Phoenix.Router 51 | import Plug.Conn 52 | import Phoenix.Controller 53 | end 54 | end 55 | 56 | def channel do 57 | quote do 58 | use Phoenix.Channel 59 | import TmateWeb.Gettext 60 | end 61 | end 62 | 63 | @doc """ 64 | When used, dispatch to the appropriate controller/view/etc. 65 | """ 66 | defmacro __using__(which) when is_atom(which) do 67 | apply(__MODULE__, which, []) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/tmate_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", TmateWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # TmateWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /lib/tmate_web/controllers/internal_api_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.InternalApiController do 2 | use TmateWeb, :controller 3 | alias Tmate.Session 4 | alias Tmate.User 5 | require Logger 6 | import Ecto.Query 7 | alias Tmate.Repo 8 | 9 | alias Tmate.Util.JsonApi 10 | 11 | # We come here authenticated 12 | 13 | def webhook(conn, event_payload) do 14 | # Note: the incoming data is trusted, it's okay to convert to atom. 15 | event_payload = 16 | event_payload 17 | |> JsonApi.with_atom_keys() 18 | |> JsonApi.as_atom(:type) 19 | |> JsonApi.as_timestamp(:timestamp) 20 | 21 | %{type: type, entity_id: entity_id, 22 | timestamp: timestamp, generation: generation, 23 | params: params} = event_payload 24 | 25 | Tmate.Event.emit!(type, entity_id, timestamp, generation, params) 26 | 27 | conn 28 | |> json(%{}) 29 | end 30 | 31 | def get_session(conn, %{"token" => token}) do 32 | session = Repo.one(from s in Session, 33 | where: s.stoken == ^token or s.stoken_ro == ^token, 34 | select: %{id: s.id, 35 | ssh_cmd_fmt: s.ssh_cmd_fmt, 36 | created_at: s.created_at, 37 | disconnected_at: s.disconnected_at, 38 | closed: s.closed}, 39 | order_by: [desc: s.created_at], 40 | limit: 1) 41 | if session do 42 | conn 43 | |> json(session) 44 | else 45 | conn 46 | |> put_status(404) 47 | |> json(%{error: "not found"}) 48 | end 49 | end 50 | 51 | def get_named_session_prefix(conn, %{"api_key" => api_key}) do 52 | user = User.get_by_api_key(api_key) 53 | if user do 54 | prefix = "#{user.username}/" 55 | result = %{prefix: prefix} 56 | conn 57 | |> json(result) 58 | else 59 | conn 60 | |> put_status(404) 61 | |> json(%{error: "api key not found"}) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/tmate_web/controllers/sign_up_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.SignUpController do 2 | use TmateWeb, :controller 3 | 4 | alias Tmate.Util.EctoHelpers 5 | alias Tmate.User 6 | alias Tmate.Repo 7 | 8 | require Logger 9 | 10 | def new(conn, _params) do 11 | flash_msg = get_flash(conn, :registration) 12 | 13 | changeset = User.changeset(%User{}, %{allow_mailing_list: true}) 14 | conn 15 | |> clear_flash() 16 | |> render("home.html", changeset: changeset, flash_info: flash_msg) 17 | end 18 | 19 | def create(conn, %{"user"=> %{"email"=> email}=user_params}) do 20 | # When a user try to sign up with an email corresponding to an existing 21 | # verified account, we'll send the credentials that we already have. 22 | # Otherwise, we proceed to creating the account. 23 | case Repo.get_by(User, email: email) do 24 | %{id: user_id, verified: true} -> 25 | Tmate.Event.emit!(:email_api_key, user_id, %{again: true}) 26 | 27 | conn 28 | |> put_flash(:registration, "Your API key has been sent to #{email}" 29 | <> ". If you need a new API key, please contact us at support@tmate.io") 30 | |> redirect(to: "#{Routes.sign_up_path(conn, :new)}#api_key") 31 | %{id: user_id, verified: false} -> 32 | Tmate.Event.emit!(:expire_user, user_id, %{}) 33 | create_stub(conn, user_params) 34 | _ -> 35 | create_stub(conn, user_params) 36 | end 37 | end 38 | 39 | defp create_stub(conn, %{"email" => email, "username" => username}=user_params) do 40 | case Repo.get_by(User, username: username) do 41 | %{id: user_id, verified: false} -> 42 | Tmate.Event.emit!(:expire_user, user_id, %{}) 43 | _ -> nil 44 | end 45 | 46 | user_id = UUID.uuid1() 47 | changeset = User.changeset(%User{id: user_id}, user_params) 48 | 49 | # Note that validate_changeset() will test the uniqueness validations 50 | case EctoHelpers.validate_changeset(changeset) do 51 | {:error, changeset} -> 52 | Logger.warn("signup invalid: #{inspect(changeset)}") 53 | conn 54 | |> put_status(400) 55 | |> render("home.html", changeset: changeset, flash_info: nil) 56 | :ok -> 57 | Tmate.Event.emit!(:user_create, user_id, changeset.changes) 58 | Tmate.Event.emit!(:email_api_key, user_id, %{}) 59 | conn 60 | |> put_flash(:registration, "Your API key has been sent to #{email}") 61 | |> redirect(to: "#{Routes.sign_up_path(conn, :new)}#api_key") 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/tmate_web/controllers/terminal_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.TerminalController do 2 | use TmateWeb, :controller 3 | 4 | alias Tmate.Repo 5 | alias Tmate.Session 6 | import Ecto.Query 7 | 8 | def show(conn, %{"token" => token_path}) do 9 | token = Enum.join(token_path, "/") # tokens can have /, and comes in as an array 10 | conn 11 | |> delete_resp_header("x-frame-options") # so it's embeddable in iframes. 12 | |> render("show.html", token: token) 13 | end 14 | 15 | def show_json(conn, %{"token" => token_path}) do 16 | token = Enum.join(token_path, "/") # tokens can have /, and comes in as an array 17 | 18 | session = Repo.one(from s in Session, where: s.stoken == ^token or s.stoken_ro == ^token, 19 | select: %{ws_url_fmt: s.ws_url_fmt, 20 | ssh_cmd_fmt: s.ssh_cmd_fmt, 21 | created_at: s.created_at, 22 | disconnected_at: s.disconnected_at, 23 | closed: s.closed}, 24 | order_by: [desc: s.created_at], 25 | limit: 1) 26 | if session do 27 | # Compat for the UI 28 | closed_at = if session.closed, do: session.disconnected_at, else: nil 29 | session = Map.merge(session, %{closed_at: closed_at}) 30 | 31 | conn 32 | |> json(session) 33 | else 34 | :timer.sleep(:crypto.rand_uniform(50, 200)) 35 | conn 36 | |> put_status(404) 37 | |> json(%{error: "not found"}) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tmate_web/emails/email.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Email do 2 | use Bamboo.Phoenix, view: TmateWeb.EmailView 3 | 4 | def api_key_email(%{email: email, username: username, api_key: api_key}) do 5 | base_email() 6 | |> to(email) 7 | |> subject("Your tmate API key") 8 | |> assign(:api_key, api_key) 9 | |> assign(:username, username) 10 | |> render(:api_key) 11 | end 12 | 13 | defp base_email do 14 | new_email() 15 | |> from(Application.fetch_env!(:tmate, Tmate.Mailer)[:from]) 16 | |> put_html_layout({TmateWeb.LayoutView, "email.html"}) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tmate_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :tmate 3 | 4 | socket "/socket", TmateWeb.UserSocket, 5 | websocket: true, 6 | longpoll: false 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phx.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :tmate, 15 | gzip: true, 16 | only: ~w(css fonts img js favicon.ico robots.txt) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 22 | plug Phoenix.LiveReloader 23 | plug Phoenix.CodeReloader 24 | end 25 | 26 | plug Tmate.Util.PlugRemoteIp 27 | # plug Plug.RequestId 28 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 29 | # plug Plug.Logger 30 | 31 | plug Plug.Parsers, 32 | parsers: [:urlencoded, :multipart, :json], 33 | pass: ["*/*"], 34 | json_decoder: Phoenix.json_library() 35 | 36 | plug Plug.MethodOverride 37 | plug Plug.Head 38 | 39 | # The session will be stored in the cookie and signed, 40 | # this means its contents can be read but not tampered with. 41 | # Set :encryption_salt if you would also like to encrypt it. 42 | plug Plug.Session, 43 | store: :cookie, 44 | key: "_tmate_key", 45 | signing_salt: "PlqZqmWt", 46 | encryption_salt: "vIeLihup" 47 | 48 | plug TmateWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /lib/tmate_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import TmateWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :tmate 24 | end 25 | -------------------------------------------------------------------------------- /lib/tmate_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.Router do 2 | use TmateWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | plug :fetch_session 15 | plug :put_secure_browser_headers 16 | end 17 | 18 | pipeline :internal_api do 19 | plug :accepts, ["json"] 20 | 21 | def internal_api_opts do 22 | # XXX We can't pass the auth token directly, it is not 23 | # necessarily defined at compile time. 24 | Application.fetch_env!(:tmate, :master)[:internal_api] 25 | end 26 | plug Tmate.Util.PlugVerifyAuthToken, fn_opts: &__MODULE__.internal_api_opts/0 27 | end 28 | 29 | scope "/api", TmateWeb do 30 | pipe_through :api 31 | 32 | get "/t/*token", TerminalController, :show_json 33 | end 34 | 35 | scope "/internal_api", TmateWeb do 36 | pipe_through :internal_api 37 | post "/webhook", InternalApiController, :webhook 38 | get "/session", InternalApiController, :get_session 39 | get "/named_session_prefix", InternalApiController, :get_named_session_prefix 40 | end 41 | 42 | scope "/", TmateWeb do 43 | pipe_through :browser 44 | 45 | get "/t/*token", TerminalController, :show 46 | 47 | get "/", SignUpController, :new 48 | post "/", SignUpController, :create 49 | end 50 | 51 | if Mix.env == :dev do 52 | # If using Phoenix 53 | forward "/sent_emails", Bamboo.SentEmailViewerPlug 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/tmate_web/templates/email/api_key.html.eex: -------------------------------------------------------------------------------- 1 |

    Dear tmate user,

    2 | 3 |

    Your API key is: <%= @api_key %>

    4 | 5 |

    You can use it to name sessions as such:

    6 | 7 |
      8 |
    • From the CLI: 9 |
      10 | tmate -k <%= @api_key %> -n testname
      11 |
    • 12 | 13 |
    • 14 | Or from the ~/.tmate.conf file: 15 |
      16 | set tmate-api-key "<%= @api_key %>"
      17 | set tmate-session-name "testname"
      18 |
    • 19 | 20 |

      It is also useful to put the API key in the tmate configuration file, 21 | and specify the session name on the CLI.

      22 | 23 |

      Note that tmate version should be at least 2.4.0. 24 | Check tmate version by running: tmate -V

      25 | 26 |

      27 | Access control must be considered when using named sessions, see https://tmate.io/#access_control. 28 |

      29 | 30 |

      Good day,
      31 | Jackie the robot

      32 | -------------------------------------------------------------------------------- /lib/tmate_web/templates/email/api_key.text.eex: -------------------------------------------------------------------------------- 1 | Dear tmate user, 2 | 3 | Your API key is: <%= @api_key %> 4 | 5 | You can use it to name sessions as such: 6 | 7 | From the CLI: 8 | tmate -k <%= @api_key %> -n testname 9 | 10 | Or from the ~/.tmate.conf file: 11 | set tmate-api-key "<%= @api_key %>" 12 | set tmate-session-name "testname" 13 | 14 | It is also useful to put the API key in the tmate configuration file, 15 | and specify the session name on the CLI. 16 | 17 | Note that tmate version should be at least 2.4.0. 18 | Check tmate version by running: tmate -V 19 | 20 | /!\ Access control must be considered when using named sessions, 21 | see https://tmate.io/#access_control 22 | 23 | Good day, 24 | Jackie the robot 25 | -------------------------------------------------------------------------------- /lib/tmate_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= @view_module.title(@view_template, assigns) %> 14 | 15 | 16 | 17 | <%= render @view_module, @view_template, assigns %> 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /lib/tmate_web/templates/layout/email.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 420 | 421 | 422 | 423 | 424 | 425 | 444 | 445 | 446 |
      426 |
      427 | 428 | 429 | 430 | 439 | 440 |
      431 | 432 | 433 | 436 | 437 |
      434 | <%= render @view_module, @view_template, assigns %> 435 |
      438 |
      441 | 442 |
      443 |
      447 | 448 | 449 | -------------------------------------------------------------------------------- /lib/tmate_web/templates/terminal/show.html.eex: -------------------------------------------------------------------------------- 1 |
      2 |

      3 | Loading... 4 |

      5 |
      6 | 7 | 8 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/tmate_web/views/email_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.EmailView do 2 | use TmateWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/tmate_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "help-block") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(TmateWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(TmateWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | 46 | # old changesetview 47 | # defmodule Tmate.ChangesetView do 48 | # use TmateWeb, :view 49 | 50 | # def render("error.json", %{changeset: changeset}) do 51 | # validation_errors = Enum.reduce(changeset.errors, %{}, fn {field, error}, errors -> 52 | # Map.merge(errors, %{field => render_changeset_error(error)}) 53 | # end) 54 | 55 | # if Enum.empty?(validation_errors) do 56 | # raise "No validation errors for #{inspect(changeset)}" 57 | # end 58 | 59 | # %{validation_errors: validation_errors} 60 | # end 61 | 62 | # def render_changeset_error({message, values}) do 63 | # Enum.reduce values, message, fn {k, v}, acc -> 64 | # String.replace(acc, "%{#{k}}", to_string(v)) 65 | # end 66 | # end 67 | 68 | # def render_changeset_error(message) do 69 | # message 70 | # end 71 | # end 72 | -------------------------------------------------------------------------------- /lib/tmate_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.ErrorView do 2 | use TmateWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | 18 | 19 | # old error view 20 | # defmodule Tmate.ErrorView do 21 | # use TmateWeb, :view 22 | 23 | # def render("404.html", _assigns) do 24 | # "Page not found" 25 | # end 26 | 27 | # def render("500.html", _assigns) do 28 | # "Server internal error" 29 | # end 30 | 31 | # def render("500.json", _assigns) do 32 | # %{error: "Server internal error"} 33 | # end 34 | 35 | # # In case no render clause matches or no 36 | # # template is found, let's render it as 500 37 | # def template_not_found(_template, assigns) do 38 | # render "500.html", assigns 39 | # end 40 | # end 41 | -------------------------------------------------------------------------------- /lib/tmate_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.LayoutView do 2 | use TmateWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/tmate_web/views/sign_up_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.SignUpView do 2 | use TmateWeb, :view 3 | 4 | def title("home.html", _assigns) do 5 | "tmate • Instant terminal sharing" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tmate_web/views/terminal_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.TerminalView do 2 | use TmateWeb, :view 3 | 4 | def title("show.html", %{token: token}) do 5 | "tmate • #{token}" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :tmate, 7 | version: "0.1.1", 8 | elixir: "~> 1.9", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Tmate.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.4.11"}, 37 | {:phoenix_pubsub, "~> 1.1"}, 38 | {:phoenix_ecto, "~> 4.0"}, 39 | {:ecto_sql, "~> 3.1"}, 40 | {:postgrex, ">= 0.0.0"}, 41 | {:phoenix_html, "~> 2.11"}, 42 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 43 | {:gettext, "~> 0.11"}, 44 | {:jason, "~> 1.0"}, 45 | {:cowboy, "~> 2.0"}, 46 | {:plug_cowboy, "~> 2.0"}, 47 | {:uuid, "~> 1.1" }, 48 | {:ex_machina, ">= 0.0.0", only: :test}, 49 | {:distillery, "~> 2.0"}, 50 | {:prometheus_plugs, "~> 1.1.1"}, 51 | {:prometheus_phoenix, "~> 1.3.0"}, 52 | {:prometheus_ecto, "~> 1.4.1"}, 53 | {:quantum, "~> 2.3"}, 54 | {:timex, "~> 3.0"}, 55 | {:httpoison, ">= 0.0.0"}, 56 | {:bamboo, "~> 1.3"}, 57 | {:bamboo_smtp, "~> 3.0.0"}, 58 | ] 59 | end 60 | 61 | # Aliases are shortcuts or tasks specific to the current project. 62 | # For example, to create, migrate and run the seeds file at once: 63 | # 64 | # $ mix ecto.setup 65 | # 66 | # See the documentation for `Mix` for more info on aliases. 67 | defp aliases do 68 | [ 69 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 70 | "ecto.reset": ["ecto.drop", "ecto.setup"], 71 | test: ["ecto.create --quiet", "ecto.migrate", "test"] 72 | ] 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"}, 3 | "artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"}, 4 | "bamboo": {:hex, :bamboo, "1.3.0", "9ab7c054f1c3435464efcba939396c29c5e1b28f73c34e1f169e0881297a3141", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, 7 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, 8 | "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"}, 10 | "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, 11 | "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, 13 | "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "ecto": {:hex, :ecto, "3.2.5", "76c864b77948a479e18e69cc1d0f0f4ee7cced1148ffe6a093ff91eba644f0b5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 15 | "ecto_sql": {:hex, :ecto_sql, "3.2.1", "4eed4100cbb2abcff10c46660d6613693807bf64f1b865f414daccf762d3758d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"}, 17 | "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"}, 18 | "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, 19 | "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, 20 | "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm"}, 21 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 23 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 25 | "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, 26 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 27 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 28 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 29 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 30 | "phoenix": {:hex, :phoenix, "1.4.11", "d112c862f6959f98e6e915c3b76c7a87ca3efd075850c8daa7c3c7a609014b0d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 31 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 32 | "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 33 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, 34 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, 35 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, 36 | "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 37 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 38 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 39 | "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 40 | "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm"}, 41 | "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, 42 | "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, 43 | "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, 44 | "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"}, 45 | "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, 46 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 47 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, 48 | "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, 49 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, 50 | "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, 51 | "tzdata": {:hex, :tzdata, "1.0.2", "6c4242c93332b8590a7979eaf5e11e77d971e579805c44931207e32aa6ad3db1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 52 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 53 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, 54 | } 55 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /priv/repo/console.exs: -------------------------------------------------------------------------------- 1 | # run: import_file("priv/repo/console.exs") 2 | # run: import_file("lib/tmate-0.1.1/priv/repo/console.exs") 3 | 4 | alias Tmate.Repo 5 | alias Tmate.Client 6 | alias Tmate.Session 7 | alias Tmate.Event 8 | alias Tmate.User 9 | alias Tmate.Identity 10 | import Ecto.Query 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151010162127_initial.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.Initial do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:events) do 6 | add :type, :string, null: false 7 | add :entity_id, :uuid 8 | add :timestamp, :utc_datetime, null: false 9 | add :params, :map, null: false 10 | end 11 | create index(:events, [:type]) 12 | create index(:events, [:entity_id]) 13 | 14 | create table(:identities) do 15 | add :type, :string, null: false 16 | add :key, :string, size: 1024, null: false 17 | end 18 | create index(:identities, [:type, :key], [unique: true]) 19 | 20 | create table(:sessions, primary_key: false) do 21 | add :id, :uuid, primary_key: true 22 | add :host_identity_id, references(:identities, type: :integer), null: false 23 | add :host_last_ip, :string, null: false 24 | add :ws_base_url, :string, null: false 25 | add :stoken, :string, size: 30, null: false 26 | add :stoken_ro, :string, size: 30, null: false 27 | add :created_at, :utc_datetime, null: false 28 | add :closed_at, :utc_datetime 29 | end 30 | create index(:sessions, [:host_identity_id]) 31 | create index(:sessions, [:stoken]) 32 | create index(:sessions, [:stoken_ro]) 33 | 34 | create table(:clients, primary_key: false) do 35 | add :session_id, references(:sessions, type: :uuid, on_delete: :delete_all), null: false 36 | add :identity_id, references(:identities, type: :integer), null: false 37 | add :client_id, :integer, null: false 38 | add :ip_address, :string, null: false 39 | add :joined_at, :utc_datetime, null: false 40 | add :readonly, :boolean, null: false 41 | end 42 | create index(:clients, [:session_id, :client_id], [unique: true]) 43 | create index(:clients, [:session_id]) 44 | create index(:clients, [:client_id]) 45 | 46 | create table(:users, primary_key: false) do 47 | add :id, :uuid, primary_key: true 48 | add :email, :string, null: false 49 | add :name, :string, null: false 50 | add :nickname, :string, null: false 51 | add :github_login, :string 52 | add :github_access_token, :string 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /priv/repo/migrations/20151221142603_key_size.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.KeySize do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:identities) do 6 | modify :key, :string, size: 4096, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160121023039_add_identity_metadata.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddMetadataIdentity do 2 | use Ecto.Migration 3 | 4 | # alias Tmate.Repo 5 | # alias Tmate.Identity 6 | 7 | def change do 8 | alter table(:identities) do 9 | add :metadata, :map 10 | end 11 | 12 | flush() 13 | 14 | # Repo.all(Identity |> Identity.type("ssh")) 15 | # |> Enum.each(fn identity -> 16 | # identity |> migrate_ssh_key() |> Repo.update!() 17 | # end) 18 | 19 | alter table(:identities) do 20 | modify :key, :string, size: 64, null: false 21 | end 22 | end 23 | 24 | # defp migrate_ssh_key(identity) do 25 | # params = case {identity.type, identity.key} do 26 | # {"ssh", "SHA256:" <> _} -> %{} 27 | # {"ssh", _key} -> %{key: Identity.key_hash(identity.key), metadata: %{pubkey: identity.key}} 28 | # _ -> %{} 29 | # end 30 | # Identity.changeset(identity, params) 31 | # end 32 | end 33 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160123063003_add_connection_fmt.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddConnectionFmt do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sessions) do 6 | remove :ws_base_url 7 | add :ws_url_fmt, :string 8 | add :ssh_cmd_fmt, :string 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160304084101_add_client_stats.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddClientStats do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:clients) do 6 | add :left_at, :utc_datetime 7 | add :latency_stats, :map 8 | end 9 | 10 | alter table(:sessions) do 11 | add :host_latency_stats, :map 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160328175128_client_id_uuid.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.ClientIdUuid do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:clients) do 6 | add :id, :uuid 7 | end 8 | 9 | flush() 10 | Ecto.Adapters.SQL.query(Tmate.Repo, "update clients set id = md5(random()::text || clock_timestamp()::text)::uuid", []) 11 | 12 | drop index(:clients, [:session_id, :client_id], [unique: true]) 13 | drop index(:clients, [:client_id]) 14 | 15 | alter table(:clients) do 16 | modify :id, :uuid, primary_key: true 17 | remove :client_id 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160406210826_github_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.GithubUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | rename table(:users), :nickname, to: :username 6 | alter table(:users) do 7 | remove :github_access_token 8 | remove :github_login 9 | remove :name 10 | add :github_id, :integer 11 | end 12 | create index(:users, [:username], unique: true) 13 | create index(:users, [:email], unique: true) 14 | create index(:users, [:github_id], unique: true, where: "github_id is not null") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190904041603_add_disconnect_at.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddDisconnectAt do 2 | use Ecto.Migration 3 | 4 | def change do 5 | # Simplify things a bit. 6 | 7 | Ecto.Adapters.SQL.query(Tmate.Repo, "delete from sessions where closed_at is not null", []) 8 | Ecto.Adapters.SQL.query(Tmate.Repo, "delete from clients where left_at is not null", []) 9 | flush() 10 | 11 | alter table(:sessions) do 12 | add :disconnected_at, :utc_datetime 13 | remove :closed_at 14 | end 15 | 16 | alter table(:clients) do 17 | remove :left_at 18 | remove :latency_stats 19 | end 20 | 21 | alter table(:sessions) do 22 | remove :host_latency_stats 23 | end 24 | 25 | # Assume the worst for existing sessions. 26 | flush() 27 | Ecto.Adapters.SQL.query(Tmate.Repo, "update sessions set disconnected_at = clock_timestamp()", []) 28 | 29 | # Note: Sessions that are disconnected for too long should be pruned 30 | # We could have some sort of timer 31 | create index(:sessions, [:disconnected_at]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191005234200_add_generation.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddGeneration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:events) do 6 | add :generation, :integer 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191014044039_add_closed_at.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddClosedAt do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sessions) do 6 | add :closed, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191108161753_remove_identity_one.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.RemoveIdentityOne do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sessions) do 6 | modify :host_identity_id, :integer, null: true 7 | end 8 | 9 | drop constraint(:sessions, "sessions_host_identity_id_fkey") 10 | 11 | alter table(:clients) do 12 | modify :identity_id, :integer, null: true 13 | end 14 | 15 | drop constraint(:clients, "clients_identity_id_fkey") 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191108174232_remove_identity_three.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.RemoveIdentityThree do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sessions) do 6 | remove :host_identity_id 7 | end 8 | 9 | alter table(:clients) do 10 | remove :identity_id 11 | end 12 | 13 | drop table(:identities) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191110232601_remove_github_id.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.RemoveGithubId do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | remove :github_id 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191110232704_expand_token_size.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.ExpandTokenSize do 2 | use Ecto.Migration 3 | 4 | def change do 5 | drop index(:sessions, [:stoken]) 6 | drop index(:sessions, [:stoken_ro]) 7 | 8 | flush() 9 | 10 | alter table(:sessions) do 11 | modify :stoken, :string, size: 255 12 | modify :stoken_ro, :string, size: 255 13 | end 14 | 15 | create index(:sessions, [:stoken]) 16 | create index(:sessions, [:stoken_ro]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20191111025821_add_api_key.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddApiKey do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :api_key, :string 7 | add :verified, :boolean, default: false 8 | add :allow_mailing_list, :boolean, default: false 9 | add :created_at, :utc_datetime 10 | add :last_seen_at, :utc_datetime 11 | end 12 | 13 | create index(:users, [:api_key], [unique: true]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200725202312_add_generation_session.exs: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Repo.Migrations.AddGenerationSession do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:sessions) do 6 | add :generation, :integer 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Tmate.Repo.insert!(%Tmate.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/repo/structure.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 11.4 6 | -- Dumped by pg_dump version 11.4 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SELECT pg_catalog.set_config('search_path', '', false); 14 | SET check_function_bodies = false; 15 | SET xmloption = content; 16 | SET client_min_messages = warning; 17 | SET row_security = off; 18 | 19 | SET default_tablespace = ''; 20 | 21 | SET default_with_oids = false; 22 | 23 | -- 24 | -- Name: clients; Type: TABLE; Schema: public; Owner: - 25 | -- 26 | 27 | CREATE TABLE public.clients ( 28 | session_id uuid NOT NULL, 29 | ip_address character varying(255) NOT NULL, 30 | joined_at timestamp(0) without time zone NOT NULL, 31 | readonly boolean NOT NULL, 32 | id uuid NOT NULL 33 | ); 34 | 35 | 36 | -- 37 | -- Name: events; Type: TABLE; Schema: public; Owner: - 38 | -- 39 | 40 | CREATE TABLE public.events ( 41 | id bigint NOT NULL, 42 | type character varying(255) NOT NULL, 43 | entity_id uuid, 44 | "timestamp" timestamp(0) without time zone NOT NULL, 45 | params jsonb NOT NULL, 46 | generation integer 47 | ); 48 | 49 | 50 | -- 51 | -- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: - 52 | -- 53 | 54 | CREATE SEQUENCE public.events_id_seq 55 | START WITH 1 56 | INCREMENT BY 1 57 | NO MINVALUE 58 | NO MAXVALUE 59 | CACHE 1; 60 | 61 | 62 | -- 63 | -- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - 64 | -- 65 | 66 | ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id; 67 | 68 | 69 | -- 70 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - 71 | -- 72 | 73 | CREATE TABLE public.schema_migrations ( 74 | version bigint NOT NULL, 75 | inserted_at timestamp(0) without time zone 76 | ); 77 | 78 | 79 | -- 80 | -- Name: sessions; Type: TABLE; Schema: public; Owner: - 81 | -- 82 | 83 | CREATE TABLE public.sessions ( 84 | id uuid NOT NULL, 85 | host_last_ip character varying(255) NOT NULL, 86 | stoken character varying(255) NOT NULL, 87 | stoken_ro character varying(255) NOT NULL, 88 | created_at timestamp(0) without time zone NOT NULL, 89 | ws_url_fmt character varying(255), 90 | ssh_cmd_fmt character varying(255), 91 | disconnected_at timestamp(0) without time zone, 92 | closed boolean DEFAULT false 93 | ); 94 | 95 | 96 | -- 97 | -- Name: users; Type: TABLE; Schema: public; Owner: - 98 | -- 99 | 100 | CREATE TABLE public.users ( 101 | id uuid NOT NULL, 102 | email character varying(255) NOT NULL, 103 | username character varying(255) NOT NULL, 104 | api_key character varying(255), 105 | verified boolean DEFAULT false, 106 | allow_mailing_list boolean DEFAULT false, 107 | created_at timestamp(0) without time zone, 108 | last_seen_at timestamp(0) without time zone 109 | ); 110 | 111 | 112 | -- 113 | -- Name: events id; Type: DEFAULT; Schema: public; Owner: - 114 | -- 115 | 116 | ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass); 117 | 118 | 119 | -- 120 | -- Name: clients clients_pkey; Type: CONSTRAINT; Schema: public; Owner: - 121 | -- 122 | 123 | ALTER TABLE ONLY public.clients 124 | ADD CONSTRAINT clients_pkey PRIMARY KEY (id); 125 | 126 | 127 | -- 128 | -- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: - 129 | -- 130 | 131 | ALTER TABLE ONLY public.events 132 | ADD CONSTRAINT events_pkey PRIMARY KEY (id); 133 | 134 | 135 | -- 136 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - 137 | -- 138 | 139 | ALTER TABLE ONLY public.schema_migrations 140 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); 141 | 142 | 143 | -- 144 | -- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: - 145 | -- 146 | 147 | ALTER TABLE ONLY public.sessions 148 | ADD CONSTRAINT sessions_pkey PRIMARY KEY (id); 149 | 150 | 151 | -- 152 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - 153 | -- 154 | 155 | ALTER TABLE ONLY public.users 156 | ADD CONSTRAINT users_pkey PRIMARY KEY (id); 157 | 158 | 159 | -- 160 | -- Name: clients_session_id_index; Type: INDEX; Schema: public; Owner: - 161 | -- 162 | 163 | CREATE INDEX clients_session_id_index ON public.clients USING btree (session_id); 164 | 165 | 166 | -- 167 | -- Name: events_entity_id_index; Type: INDEX; Schema: public; Owner: - 168 | -- 169 | 170 | CREATE INDEX events_entity_id_index ON public.events USING btree (entity_id); 171 | 172 | 173 | -- 174 | -- Name: events_type_index; Type: INDEX; Schema: public; Owner: - 175 | -- 176 | 177 | CREATE INDEX events_type_index ON public.events USING btree (type); 178 | 179 | 180 | -- 181 | -- Name: sessions_disconnected_at_index; Type: INDEX; Schema: public; Owner: - 182 | -- 183 | 184 | CREATE INDEX sessions_disconnected_at_index ON public.sessions USING btree (disconnected_at); 185 | 186 | 187 | -- 188 | -- Name: sessions_stoken_index; Type: INDEX; Schema: public; Owner: - 189 | -- 190 | 191 | CREATE INDEX sessions_stoken_index ON public.sessions USING btree (stoken); 192 | 193 | 194 | -- 195 | -- Name: sessions_stoken_ro_index; Type: INDEX; Schema: public; Owner: - 196 | -- 197 | 198 | CREATE INDEX sessions_stoken_ro_index ON public.sessions USING btree (stoken_ro); 199 | 200 | 201 | -- 202 | -- Name: users_api_key_index; Type: INDEX; Schema: public; Owner: - 203 | -- 204 | 205 | CREATE UNIQUE INDEX users_api_key_index ON public.users USING btree (api_key); 206 | 207 | 208 | -- 209 | -- Name: users_email_index; Type: INDEX; Schema: public; Owner: - 210 | -- 211 | 212 | CREATE UNIQUE INDEX users_email_index ON public.users USING btree (email); 213 | 214 | 215 | -- 216 | -- Name: users_username_index; Type: INDEX; Schema: public; Owner: - 217 | -- 218 | 219 | CREATE UNIQUE INDEX users_username_index ON public.users USING btree (username); 220 | 221 | 222 | -- 223 | -- Name: clients clients_session_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - 224 | -- 225 | 226 | ALTER TABLE ONLY public.clients 227 | ADD CONSTRAINT clients_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.sessions(id) ON DELETE CASCADE; 228 | 229 | 230 | -- 231 | -- PostgreSQL database dump complete 232 | -- 233 | 234 | INSERT INTO public."schema_migrations" (version) VALUES (20151010162127), (20151221142603), (20160121023039), (20160123063003), (20160304084101), (20160328175128), (20160406210826), (20190904041603), (20191005234200), (20191014044039), (20191108161753), (20191108174232), (20191110232601), (20191110232704), (20191111025821); 235 | 236 | -------------------------------------------------------------------------------- /priv/static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #151515; 3 | /* background-image: url('/img/rebel.png'); */ 4 | color: #bbb; 5 | font-size: 16px; 6 | } 7 | 8 | ::-webkit-selection { color: black; background: rgba(178, 104, 24, 0.99); } 9 | ::-moz-selection { color: black; background: rgba(178, 104, 24, 0.99); } 10 | ::selection { color: black; background: rgba(178, 104, 24, 0.99); } 11 | 12 | h1, h2, h3, h4, h5, h6 { 13 | color: #bbb; 14 | } 15 | 16 | .nav-tabs { 17 | font-size: 15px; 18 | min-inline-size: max-content; 19 | } 20 | 21 | .nav-tabs > li > a { 22 | padding: 7px; 23 | } 24 | 25 | 26 | li, p { 27 | line-height: 1.4em; 28 | } 29 | 30 | label, input, button, select, textarea { 31 | font-size: 1em; 32 | line-height: 1.4em; 33 | } 34 | 35 | 36 | .hero-unit { 37 | margin-top: 50px; 38 | text-align: center; 39 | background-color: rgba(0, 0, 0, 0.3); 40 | 41 | -webkit-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 42 | -moz-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 43 | box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 44 | } 45 | 46 | .hero-unit h1 { color: #fff; text-shadow: 0px 0px 5px rgba(255, 255, 255, 0.4); } 47 | .hero-unit h2 { color: #ccc; } 48 | .hero-unit p { text-align: center; } 49 | 50 | hr { 51 | border-bottom: 1px solid #3b3b3b; 52 | border-top: 0; 53 | margin-top: 35px; 54 | 55 | margin-top: 1em; 56 | margin-bottom: 2em; 57 | } 58 | 59 | code, pre { 60 | font-size: 13px; 61 | font-family: "Bitstream Vera Sans Mono", "Courier New", Courier, monospace; 62 | line-height: 1.2em; 63 | overflow: auto; 64 | background-color: rgba(0, 0, 0, 0.1); 65 | color: #bbb; 66 | -webkit-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 67 | -moz-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 68 | box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 69 | border: 0; 70 | } 71 | 72 | .fig { 73 | -webkit-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 74 | -moz-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 75 | box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset; 76 | 77 | -webkit-border-radius: 5px; 78 | -moz-border-radius: 5px; 79 | border-radius: 5px; 80 | 81 | background-color: rgba(0, 0, 0, 0.1); 82 | padding: 10px; 83 | margin-bottom: 1em; 84 | } 85 | 86 | p.fig-label { 87 | text-align: center; 88 | span { font-weight: bold; } 89 | margin-bottom: 2em; 90 | } 91 | 92 | .video { 93 | margin-top: 10px; 94 | -webkit-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 95 | -moz-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 96 | box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px; 97 | } 98 | 99 | .video.linux { 100 | background-image: url('/img/video_linux_first_frame.png'); 101 | height: 285px; 102 | width: 413px; 103 | margin-left: 30px; 104 | } 105 | 106 | .video.macos { 107 | background-image: url('/img/video_macos_first_frame.png'); 108 | height: 285px; 109 | width: 409px; 110 | float: right; 111 | margin-right: 30px; 112 | } 113 | 114 | .steps { 115 | text-align: center; 116 | margin: 0 auto; 117 | margin-top: 20px; 118 | width: 720px; 119 | height: 50px; 120 | } 121 | 122 | .steps h3 { 123 | width: 240px; 124 | float: left; 125 | display: none; 126 | margin-top: 0; 127 | margin-bottom: -40px; 128 | height: 50px; 129 | } 130 | 131 | .nav-tabs { 132 | border-bottom: 1px solid #3b3b3b; 133 | } 134 | 135 | .nav-tabs > li { 136 | margin-bottom: -1px; 137 | } 138 | 139 | .nav-tabs > li > a:hover, 140 | .nav-tabs > li > a:focus { 141 | border-color: #3b3b3b; 142 | } 143 | 144 | .nav-tabs > .active > a, 145 | .nav-tabs > .active > a:hover, 146 | .nav-tabs > .active > a:focus { 147 | color: #aaa; 148 | cursor: default; 149 | background-color: rgba(0, 0, 0, 0.3); 150 | border: 1px solid #3b3b3b; 151 | border-bottom-color: transparent; 152 | } 153 | 154 | .nav > li > a:hover, 155 | .nav > li > a:focus { 156 | text-decoration: none; 157 | color: #0088cc; 158 | background-color: rgba(0, 0, 0, 0.3); 159 | } 160 | 161 | .footer { 162 | width: 100%; 163 | height: 140px; 164 | margin-top: 40px; 165 | padding-top: 30px; 166 | background-color: rgba(0, 0, 0, 0.5); 167 | } 168 | .footer .digitalocean p { 169 | text-align: center; 170 | } 171 | 172 | .social-buttons { 173 | margin-top: 10px; 174 | margin-bottom: -30px; 175 | } 176 | 177 | .social-buttons .twitter { 178 | display: inline; 179 | margin-top: 8px; 180 | margin-right: 17px; 181 | } 182 | 183 | .social-buttons .github { 184 | display: inline; 185 | } 186 | 187 | .alert-success { 188 | color: #fff; 189 | background-color: #00ac7c; 190 | border-color: #00ac7c; 191 | text-shadow: none; 192 | } 193 | 194 | .user-registration input { 195 | color: #333; 196 | background-color: #d6d6d6; 197 | } 198 | 199 | .user-registration .help-block { 200 | color: #d88; 201 | } 202 | 203 | .user-registration button { 204 | margin-top: 0.5em; 205 | } 206 | 207 | .warning { 208 | color: #d88; 209 | } 210 | 211 | .app-container p.loading { 212 | margin: auto; 213 | margin-top: 100px; 214 | width: 200px; 215 | text-align: center; 216 | } 217 | -------------------------------------------------------------------------------- /priv/static/img/1_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/1_step.png -------------------------------------------------------------------------------- /priv/static/img/2_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/2_step.png -------------------------------------------------------------------------------- /priv/static/img/3_step.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/3_step.png -------------------------------------------------------------------------------- /priv/static/img/digitalocean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/digitalocean.png -------------------------------------------------------------------------------- /priv/static/img/fork-me-on-github-right-orange@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/fork-me-on-github-right-orange@2x.png -------------------------------------------------------------------------------- /priv/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2 -------------------------------------------------------------------------------- /priv/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf -------------------------------------------------------------------------------- /priv/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot -------------------------------------------------------------------------------- /priv/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff -------------------------------------------------------------------------------- /priv/static/img/mac_notr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/mac_notr.png -------------------------------------------------------------------------------- /priv/static/img/mac_tr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/mac_tr.png -------------------------------------------------------------------------------- /priv/static/img/rebel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/rebel.png -------------------------------------------------------------------------------- /priv/static/img/video_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_linux.png -------------------------------------------------------------------------------- /priv/static/img/video_linux_first_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_linux_first_frame.png -------------------------------------------------------------------------------- /priv/static/img/video_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_mac.png -------------------------------------------------------------------------------- /priv/static/img/video_macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_macos.png -------------------------------------------------------------------------------- /priv/static/img/video_macos_first_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_macos_first_frame.png -------------------------------------------------------------------------------- /priv/static/js/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | * If you are curious, here is how I generated the videos: 3 | * ffmpeg -an -f x11grab -r 30 -s 460x285 -i :0.0+1920,0 -pix_fmt yuv444p -vcodec libx264 -x264opts crf=0 out.mp4 4 | * mplayer -fps 3000 -vo png out.mp4 5 | * montage *.png -tile x14 -geometry '460x285' out.png 6 | * pngcrush out.png video.png 7 | * 8 | * Safari and Firefox don't like very wide pngs, so we chunk the frames in 9 | * rows. Sadly it makes the png bigger. 10 | */ 11 | 12 | $(function() { 13 | var video_linux = $('.video.linux'); 14 | var video_macos = $('.video.macos'); 15 | var current_frame = 0; 16 | var timer; 17 | 18 | var nextFrameFor = function(video, rows, total_frames, offset) { 19 | var frame = current_frame - offset; 20 | if (frame < 0) 21 | return; 22 | 23 | var frames_per_row = Math.ceil(total_frames / rows); 24 | var x = video.width() * (frame % frames_per_row); 25 | var y = video.height() * Math.floor(frame / frames_per_row); 26 | var position = "-" + x + "px -" + y + "px"; 27 | video.css('background-position', position); 28 | } 29 | 30 | var nextFrame = function() { 31 | if (current_frame == 90) 32 | $('.steps .launch').fadeIn(900); 33 | if (current_frame == 250) 34 | $('.steps .share').fadeIn(900); 35 | if (current_frame == 410) 36 | $('.steps .pair').fadeIn(900); 37 | 38 | nextFrameFor(video_linux, 12, 465, 0); 39 | nextFrameFor(video_macos, 6, 231, 234); 40 | 41 | current_frame += 1; 42 | if (current_frame >= 465) 43 | clearTimeout(timer); 44 | }; 45 | 46 | var startPlayback = function() { 47 | timer = setInterval(nextFrame, 33); 48 | }; 49 | 50 | $("").load(function() { 51 | $("").load(function() { 52 | video_linux.css('background-image', "url('/img/video_linux.png')"); 53 | video_macos.css('background-image', "url('/img/video_macos.png')"); 54 | setTimeout(startPlayback, 2000); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /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/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint TmateWeb.Endpoint 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use TmateWeb.ConnCase, async: true`, although 15 | this option is not recommendded for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | use Phoenix.ConnTest 24 | import Tmate.Factory 25 | alias TmateWeb.Router.Helpers, as: Routes 26 | 27 | # The default endpoint for testing 28 | @endpoint TmateWeb.Endpoint 29 | end 30 | end 31 | 32 | setup tags do 33 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo) 34 | 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()}) 37 | end 38 | 39 | {:ok, conn: Phoenix.ConnTest.build_conn()} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use TmateWeb.DataCase, async: true`, although 14 | this option is not recommendded for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Tmate.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Tmate.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()}) 35 | end 36 | 37 | :ok 38 | end 39 | 40 | @doc """ 41 | A helper that transforms changeset errors into a map of messages. 42 | 43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 44 | assert "password is too short" in errors_on(changeset).password 45 | assert %{password: ["password is too short"]} = errors_on(changeset) 46 | 47 | """ 48 | def errors_on(changeset) do 49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 50 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 52 | end) 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/support/event_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.EventCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Tmate.Repo 20 | import Ecto.Query 21 | import Tmate.EventCase 22 | import Tmate.Factory 23 | end 24 | end 25 | 26 | 27 | # Import conveniences for testing with connections 28 | use Phoenix.ConnTest 29 | # The default endpoint for testing 30 | @endpoint TmateWeb.Endpoint 31 | 32 | @doc """ 33 | Helper for returning list of errors in model when passed certain data. 34 | 35 | ## Examples 36 | 37 | Given a User model that lists `:name` as a required field and validates 38 | `:password` to be safe, it would return: 39 | 40 | iex> errors_on(%User{}, password: "password") 41 | [password: "is unsafe", name: "is blank"] 42 | 43 | You could then write your assertion like: 44 | 45 | assert {:password, "is unsafe"} in errors_on(%User{}, password: "password") 46 | 47 | You can also create the changeset manually and retrieve the errors 48 | field directly: 49 | 50 | iex> changeset = User.changeset(%User{}, password: "password") 51 | iex> {:password, "is unsafe"} in changeset.errors 52 | true 53 | """ 54 | def errors_on(model, data) do 55 | model.__struct__.changeset(model, data).errors 56 | end 57 | 58 | setup tags do 59 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo) 60 | 61 | unless tags[:async] do 62 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()}) 63 | end 64 | 65 | {:ok, %{}} 66 | end 67 | 68 | def emit_event(event, timestamp \\ DateTime.utc_now) do 69 | {m, params} = Map.split(event, [:event_type, :entity_id, :generation]) 70 | generation = m[:generation] || 1 71 | emit_raw_event(m[:event_type], m[:entity_id], timestamp, generation, params) 72 | event 73 | end 74 | 75 | def emit_raw_event(event_type, entity_id, timestamp, generation, params) do 76 | auth_token = "internal_api_auth_token" 77 | payload = Jason.encode!(%{type: event_type, entity_id: entity_id, timestamp: timestamp, 78 | generation: generation, userdata: auth_token, params: params}) 79 | 80 | build_conn() 81 | |> put_req_header("content-type", "application/json") 82 | |> put_req_header("accept", "application/json") 83 | # |> put_req_header("authorization", "Bearer " <> auth_token) 84 | |> post("/internal_api/webhook", payload) 85 | |> json_response(200) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Tmate.Factory do 2 | use ExMachina 3 | 4 | def event_session_register_factory do 5 | %{event_type: :session_register, 6 | entity_id: UUID.uuid1, 7 | ip_address: sequence(:ip, &"1.1.1.#{&1}"), 8 | ws_url_fmt: "https://tmate.io/ws/session/%s", 9 | ssh_cmd_fmt: "ssh %s@tmate.io", 10 | stoken: sequence(:token, &"STOKEN___________________RW#{&1}"), 11 | stoken_ro: sequence(:token, &"STOKEN___________________RO#{&1}"), 12 | reconnected: false 13 | } 14 | end 15 | 16 | def event_session_close_factory do 17 | %{event_type: :session_close, 18 | entity_id: UUID.uuid1} 19 | end 20 | 21 | def event_session_join_factory do 22 | %{event_type: :session_join, 23 | id: UUID.uuid1, 24 | ip_address: sequence(:ip, &"1.1.2.#{&1}"), 25 | type: "ssh", 26 | readonly: false} 27 | end 28 | 29 | def event_session_left_factory do 30 | %{event_type: :session_left, 31 | id: sequence(:client_id, & &1)} 32 | end 33 | 34 | def event_session_disconnect_factory do 35 | %{event_type: :session_disconnect, 36 | entity_id: UUID.uuid1} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | 3 | ExUnit.start() 4 | Mix.Task.run "ecto.create", ["--quiet"] 5 | Mix.Task.run "ecto.migrate", ["--quiet"] 6 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, :manual) 7 | -------------------------------------------------------------------------------- /test/tmate_web/controllers/internal_api_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.InternalApiControllerTest do 2 | use TmateWeb.ConnCase 3 | import Tmate.EventCase 4 | 5 | test "webhook" do 6 | # test authentication 7 | end 8 | 9 | describe "/internal_api/session" do 10 | test "returns session" do 11 | session_event = build(:event_session_register) 12 | created_at = DateTime.utc_now 13 | emit_event(session_event, created_at) 14 | 15 | auth_token = "internal_api_auth_token" 16 | response = 17 | build_conn() 18 | |> put_req_header("authorization", "Bearer " <> auth_token) 19 | |> get("/internal_api/session", %{token: session_event.stoken}) 20 | |> json_response(200) 21 | 22 | assert response == %{ 23 | "id" => session_event.entity_id, 24 | "ssh_cmd_fmt" => session_event.ssh_cmd_fmt, 25 | "created_at" => created_at |> DateTime.truncate(:second) |> DateTime.to_iso8601, 26 | "disconnected_at" => nil, 27 | "closed" => false 28 | } 29 | end 30 | 31 | test "404 with unknown sessions" do 32 | auth_token = "internal_api_auth_token" 33 | build_conn() 34 | |> put_req_header("authorization", "Bearer " <> auth_token) 35 | |> get("/internal_api/session", %{token: "invalid_token"}) 36 | |> json_response(404) 37 | end 38 | 39 | test "403 without the auth token" do 40 | auth_token = "invalid_auth_token" 41 | assert_error_sent 401, fn -> 42 | build_conn() 43 | |> put_req_header("authorization", "Bearer " <> auth_token) 44 | |> get("/internal_api/session", %{token: "invalid_token"}) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/tmate_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.PageControllerTest do 2 | use TmateWeb.ConnCase 3 | end 4 | -------------------------------------------------------------------------------- /test/tmate_web/controllers/sign_up_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.SignUpControllerTest do 2 | use TmateWeb.ConnCase 3 | # import Tmate.EventCase 4 | 5 | # describe "/register" do 6 | # test "create" do 7 | # params = %{username: "nico", email: "nico@tmate.io"} 8 | # response = 9 | # build_conn() 10 | # |> post("/", params) 11 | 12 | # assert response == [] 13 | # end 14 | # end 15 | end 16 | -------------------------------------------------------------------------------- /test/tmate_web/event_projections/session_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SessionTest do 2 | use Tmate.EventCase, async: true 3 | 4 | alias Tmate.Session 5 | alias Tmate.Client 6 | 7 | test "session_register" do 8 | session_event = build(:event_session_register) 9 | 10 | emit_event(session_event) 11 | assert Repo.one(from Session, select: count("*")) == 1 12 | 13 | emit_event(session_event) # duplicate event, should be okay 14 | assert Repo.one(from Session, select: count("*")) == 1 15 | 16 | session = Repo.get(Session, session_event.entity_id) 17 | 18 | assert session.host_last_ip == session_event.ip_address 19 | assert session.stoken == session_event.stoken 20 | assert session.stoken_ro == session_event.stoken_ro 21 | assert session.ws_url_fmt == session_event.ws_url_fmt 22 | assert session.ssh_cmd_fmt == session_event.ssh_cmd_fmt 23 | end 24 | 25 | test "session_close" do 26 | session_event = emit_event(build(:event_session_register)) 27 | 28 | session = Repo.get(Session, session_event.entity_id) 29 | assert session.disconnected_at == nil 30 | assert session.closed == false 31 | 32 | _client_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id)) 33 | 34 | assert Repo.one(from c in Client, select: count("*")) == 1 35 | close_event = emit_event(build(:event_session_close, entity_id: session_event.entity_id)) 36 | assert Repo.one(from c in Client, select: count("*")) == 0 37 | 38 | session = Repo.get(Session, session_event.entity_id) 39 | assert session.disconnected_at != nil 40 | assert session.closed == true 41 | 42 | emit_event(close_event) # duplicate 43 | end 44 | 45 | test "session_join" do 46 | session_event = emit_event(build(:event_session_register)) 47 | client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id)) 48 | client2_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id, 49 | type: "web", readonly: true)) 50 | emit_event(client2_event) # test for duplicate event 51 | 52 | session = Repo.get(Session, session_event.entity_id) 53 | session = Repo.preload(session, :clients) 54 | 55 | clients = session.clients 56 | assert clients |> Enum.count == 2 57 | client1 = clients |> Enum.filter(& &1.id == client1_event.id) |> Enum.at(0) 58 | client2 = clients |> Enum.filter(& &1.id == client2_event.id) |> Enum.at(0) 59 | 60 | assert client1.ip_address == client1_event.ip_address 61 | assert client1.readonly == client1_event.readonly 62 | 63 | assert client2.ip_address == client2_event.ip_address 64 | assert client2.readonly == client2_event.readonly 65 | end 66 | 67 | test "session_left" do 68 | session_event = emit_event(build(:event_session_register)) 69 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id)) 70 | client2_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id)) 71 | 72 | session = Repo.get(Session, session_event.entity_id) 73 | session = Repo.preload(session, :clients) 74 | assert session.clients |> Enum.count == 2 75 | 76 | left_event = emit_event(build(:event_session_left, entity_id: session_event.entity_id, id: client2_event.id)) 77 | 78 | session = Repo.get(Session, session_event.entity_id) 79 | session = Repo.preload(session, :clients) 80 | assert session.clients |> Enum.count == 1 81 | 82 | emit_event(left_event) # duplicate event 83 | 84 | session = Repo.get(Session, session_event.entity_id) 85 | session = Repo.preload(session, :clients) 86 | assert session.clients |> Enum.count == 1 87 | end 88 | 89 | test "session reconnect" do 90 | session_event = emit_event(build(:event_session_register)) 91 | 92 | assert Repo.get(Session, session_event.entity_id).disconnected_at == nil 93 | _disconnect_event = emit_event(build(:event_session_disconnect, entity_id: session_event.entity_id)) 94 | assert Repo.get(Session, session_event.entity_id).disconnected_at != nil 95 | 96 | session_event = emit_event(build(:event_session_register, entity_id: session_event.entity_id, 97 | reconnected: true, ssh_cmd_fmt: "new_host")) 98 | assert Repo.get(Session, session_event.entity_id).disconnected_at == nil 99 | assert Repo.get(Session, session_event.entity_id).ssh_cmd_fmt == "new_host" 100 | end 101 | 102 | test "session_disconnect/reconnect with mix generations" do 103 | session_event = emit_event(build(:event_session_register, generation: 1)) 104 | _reconnect_event = emit_event(build(:event_session_register, entity_id: session_event.entity_id, generation: 2)) 105 | 106 | _client1_event = emit_event(build(:event_session_disconnect, entity_id: session_event.entity_id, generation: 1)) 107 | 108 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id, generation: 1)) 109 | 110 | session = Repo.get(Session, session_event.entity_id) 111 | session = Repo.preload(session, :clients) 112 | assert session.clients |> Enum.count == 0 113 | 114 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id, generation: 2)) 115 | 116 | session = Repo.get(Session, session_event.entity_id) 117 | session = Repo.preload(session, :clients) 118 | assert session.clients |> Enum.count == 1 119 | end 120 | 121 | # TODO This test should probably be in a seperate file 122 | test "prune_sessions()" do 123 | se1 = emit_event(build(:event_session_register)) 124 | se2 = emit_event(build(:event_session_register)) 125 | se3 = emit_event(build(:event_session_register)) 126 | 127 | now = DateTime.utc_now 128 | _de2 = emit_event(build(:event_session_disconnect, entity_id: se2.entity_id), DateTime.add(now, -1000, :second)) 129 | _de3 = emit_event(build(:event_session_disconnect, entity_id: se3.entity_id), DateTime.add(now, -10000, :second)) 130 | 131 | assert Repo.one(from Session, select: count("*")) == 3 132 | Tmate.SessionCleaner.prune_sessions({1, "hour"}) 133 | 134 | assert (Repo.all(Session) |> Enum.map(& &1.id) |> Enum.sort()) == 135 | ([se1.entity_id, se2.entity_id] |> Enum.sort()) 136 | end 137 | 138 | # TODO This test should probably be in a seperate file 139 | test "check_for_disconnected_sessions" do 140 | defmodule WsApi do 141 | # already disconnected. Should not be checked 142 | @s1_id UUID.uuid1 143 | # will be flagged as disconnected, should be cleanup up 144 | @s2_id UUID.uuid1 145 | # will be flagged as disconnected, but reconnect events is triggered at the same time, no cleanup 146 | @s3_id UUID.uuid1 147 | # remains connected, no cleanup 148 | @s4_id UUID.uuid1 149 | 150 | def get_stale_sessions(session_ids, _base_url) do 151 | assert (session_ids |> Enum.sort()) == 152 | ([@s2_id, @s3_id, @s4_id] |> Enum.sort()) 153 | 154 | emit_event(build(:event_session_register, entity_id: @s3_id, generation: 2, reconnected: true)) 155 | 156 | {:ok, [@s2_id, @s3_id]} 157 | end 158 | 159 | def session_ids() do 160 | [@s1_id, @s2_id, @s3_id, @s4_id] 161 | end 162 | end 163 | 164 | [s1_id, s2_id, s3_id, s4_id] = WsApi.session_ids() 165 | 166 | emit_event(build(:event_session_register, entity_id: s1_id)) 167 | emit_event(build(:event_session_register, entity_id: s2_id)) 168 | emit_event(build(:event_session_register, entity_id: s3_id)) 169 | emit_event(build(:event_session_register, entity_id: s4_id)) 170 | 171 | emit_event(build(:event_session_disconnect, entity_id: s1_id)) 172 | 173 | assert Repo.get(Session, s1_id).disconnected_at != nil 174 | assert Repo.get(Session, s2_id).disconnected_at == nil 175 | assert Repo.get(Session, s3_id).disconnected_at == nil 176 | assert Repo.get(Session, s4_id).disconnected_at == nil 177 | 178 | Tmate.SessionCleaner.check_for_disconnected_sessions(SessionTest.WsApi) 179 | 180 | assert Repo.get(Session, s1_id).disconnected_at != nil 181 | assert Repo.get(Session, s2_id).disconnected_at != nil 182 | assert Repo.get(Session, s3_id).disconnected_at == nil 183 | assert Repo.get(Session, s4_id).disconnected_at == nil 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /test/tmate_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.ErrorViewTest do 2 | use TmateWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(TmateWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(TmateWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/tmate_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.LayoutViewTest do 2 | use TmateWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/tmate_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TmateWeb.PageViewTest do 2 | use TmateWeb.ConnCase, async: true 3 | end 4 | --------------------------------------------------------------------------------