├── .rustfmt.toml ├── teleterm ├── src │ ├── auth.rs │ ├── prelude.rs │ ├── web │ │ ├── logout.rs │ │ ├── view.rs │ │ ├── oauth.rs │ │ ├── ws.rs │ │ ├── disk_session.rs │ │ ├── list.rs │ │ ├── login.rs │ │ └── watch.rs │ ├── term.rs │ ├── main.rs │ ├── auth │ │ └── recurse_center.rs │ ├── key_reader.rs │ ├── cmd │ │ ├── web.rs │ │ ├── server.rs │ │ ├── record.rs │ │ └── stream.rs │ ├── dirs.rs │ ├── async_stdin.rs │ ├── cmd.rs │ ├── server │ │ └── tls.rs │ ├── config │ │ └── wizard.rs │ ├── oauth.rs │ ├── web.rs │ ├── session_list.rs │ ├── error.rs │ └── client.rs ├── static │ ├── teleterm_web_bg.wasm │ ├── teleterm.css │ └── index.html.tmpl └── Cargo.toml ├── .gitignore ├── package └── arch │ ├── README.md │ └── PKGBUILD ├── teleterm-web ├── src │ ├── prelude.rs │ ├── views.rs │ ├── views │ │ ├── list.rs │ │ ├── watch.rs │ │ ├── page.rs │ │ ├── sessions.rs │ │ ├── login.rs │ │ └── terminal.rs │ ├── config.rs │ ├── protocol.rs │ ├── lib.rs │ ├── ws.rs │ └── model.rs └── Cargo.toml ├── Cargo.toml ├── .gitlab-ci.yml ├── LICENSE ├── CHANGELOG.md ├── TODO.md ├── Makefile └── README.md /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 78 2 | -------------------------------------------------------------------------------- /teleterm/src/auth.rs: -------------------------------------------------------------------------------- 1 | pub mod recurse_center; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.log 2 | /pkg 3 | /target 4 | /teleterm/target 5 | /teleterm-web/target 6 | **/*.rs.bk 7 | -------------------------------------------------------------------------------- /teleterm/static/teleterm_web_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doy/teleterm/HEAD/teleterm/static/teleterm_web_bg.wasm -------------------------------------------------------------------------------- /package/arch/README.md: -------------------------------------------------------------------------------- 1 | ideally cargo-pkgbuild could just generate the right thing, but it's currently 2 | pretty limited. 3 | -------------------------------------------------------------------------------- /teleterm-web/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub(crate) use seed::prelude::*; 2 | pub(crate) use web_sys::{ErrorEvent, MessageEvent, WebSocket}; 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["teleterm", "teleterm-web"] 3 | default-members = ["teleterm"] 4 | 5 | [profile.release] 6 | opt-level = "z" 7 | lto = true 8 | -------------------------------------------------------------------------------- /teleterm-web/src/views.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod list; 2 | pub(crate) mod login; 3 | pub(crate) mod page; 4 | pub(crate) mod sessions; 5 | pub(crate) mod terminal; 6 | pub(crate) mod watch; 7 | -------------------------------------------------------------------------------- /teleterm/src/prelude.rs: -------------------------------------------------------------------------------- 1 | pub use futures::{Future as _, Sink as _, Stream as _}; 2 | pub use snafu::futures01::{FutureExt as _, StreamExt as _}; 3 | pub use snafu::{OptionExt as _, ResultExt as _}; 4 | 5 | pub use crate::error::{Error, Result}; 6 | pub use crate::oauth::Oauth as _; 7 | -------------------------------------------------------------------------------- /teleterm-web/src/views/list.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub(crate) fn render(model: &crate::model::Model) -> Vec> { 4 | vec![ 5 | crate::views::sessions::render(model.sessions()), 6 | seed::button![simple_ev(Ev::Click, crate::Msg::Refresh), "refresh"], 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /teleterm/static/teleterm.css: -------------------------------------------------------------------------------- 1 | .list td, .list th { 2 | margin: 0; 3 | padding-left: 8px; 4 | padding-right: 8px; 5 | } 6 | 7 | .grid { 8 | font-family: monospace; 9 | background-color: black; 10 | color: #d3d3d3; 11 | border-collapse: collapse; 12 | border: 4px solid black; 13 | } 14 | 15 | .grid .row .cell { 16 | min-width: 1ch; 17 | max-width: 1ch; 18 | min-height: 1ex; 19 | padding: 0px; 20 | } 21 | -------------------------------------------------------------------------------- /teleterm/src/web/logout.rs: -------------------------------------------------------------------------------- 1 | use gotham::state::FromState as _; 2 | 3 | pub fn run( 4 | mut state: gotham::state::State, 5 | ) -> (gotham::state::State, hyper::Response) { 6 | let session = gotham::middleware::session::SessionData::< 7 | crate::web::SessionData, 8 | >::take_from(&mut state); 9 | 10 | session.discard(&mut state).unwrap(); 11 | 12 | (state, hyper::Response::new(hyper::Body::empty())) 13 | } 14 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - build 3 | 4 | variables: 5 | RUSTFLAGS: "-D warnings" 6 | CARGO_HOME: cache 7 | 8 | cache: 9 | paths: 10 | - cache/ 11 | - target/ 12 | 13 | rust-latest: 14 | stage: build 15 | image: rust:latest 16 | script: 17 | - cargo build --locked 18 | - cargo test --locked 19 | 20 | rust-nightly: 21 | stage: build 22 | image: rustlang/rust:nightly 23 | script: 24 | - cargo build --locked 25 | - cargo test --locked 26 | allow_failure: true 27 | -------------------------------------------------------------------------------- /package/arch/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Jesse Luehrs 2 | pkgname=teleterm 3 | pkgver=0.2.0 4 | pkgrel=1 5 | makedepends=('rust' 'cargo') 6 | depends=('openssl') 7 | arch=('i686' 'x86_64' 'armv6h' 'armv7h') 8 | pkgdesc="share your terminals!" 9 | license=('MIT') 10 | 11 | build() { 12 | cargo build --release --locked 13 | } 14 | 15 | check() { 16 | cargo test --release --locked 17 | } 18 | 19 | package() { 20 | install -Dm 755 ../../../target/release/tt -t "${pkgdir}/usr/bin" 21 | } 22 | -------------------------------------------------------------------------------- /teleterm/static/index.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /teleterm-web/src/views/watch.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub(crate) fn render(model: &crate::model::Model) -> Vec> { 4 | vec![ 5 | if let Some(screen) = model.screen() { 6 | if model.received_data() { 7 | crate::views::terminal::render(screen) 8 | } else { 9 | seed::empty![] 10 | } 11 | } else { 12 | seed::empty![] 13 | }, 14 | seed::button![simple_ev(Ev::Click, crate::Msg::StopWatching), "back"], 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /teleterm-web/src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | #[wasm_bindgen] 4 | extern "C" { 5 | #[wasm_bindgen] 6 | static TELETERM_CONFIG: JsValue; 7 | } 8 | 9 | #[derive(Clone, Debug, serde::Deserialize)] 10 | pub(crate) struct Config { 11 | pub(crate) username: Option, 12 | pub(crate) public_address: String, 13 | pub(crate) allowed_login_methods: 14 | std::collections::HashSet, 15 | pub(crate) oauth_login_urls: 16 | std::collections::HashMap, 17 | } 18 | 19 | impl Config { 20 | pub(crate) fn load() -> Self { 21 | TELETERM_CONFIG.into_serde().unwrap() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /teleterm-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "teleterm-web" 3 | version = "0.1.0" 4 | authors = ["Jesse Luehrs "] 5 | edition = "2018" 6 | # this isn't intended to be published on its own - we just bake the generated 7 | # files into the main teleterm binary 8 | publish = false 9 | 10 | [lib] 11 | crate-type = ["cdylib"] 12 | 13 | [dependencies] 14 | console_log = "0.1" 15 | futures = "0.1.29" 16 | js-sys = "0.3" 17 | log = { version = "0.4", features = ["release_max_level_error"] } 18 | seed = "0.5" 19 | serde = "1" 20 | serde_json = "1" 21 | unicode-width = "0.1" 22 | vt100 = "0.8" 23 | wasm-bindgen = "0.2" 24 | web-sys = { version = "0.3", features = ["ErrorEvent", "MessageEvent", "WebSocket"] } 25 | -------------------------------------------------------------------------------- /teleterm/src/term.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] 4 | pub struct Size { 5 | pub rows: u16, 6 | pub cols: u16, 7 | } 8 | 9 | impl Size { 10 | pub fn get() -> Result { 11 | let (cols, rows) = crossterm::terminal::size() 12 | .context(crate::error::GetTerminalSize)?; 13 | Ok(Self { rows, cols }) 14 | } 15 | 16 | pub fn fits_in(self, other: Self) -> bool { 17 | self.rows <= other.rows && self.cols <= other.cols 18 | } 19 | } 20 | 21 | impl std::fmt::Display for Size { 22 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 23 | std::fmt::Display::fmt(&format!("{}x{}", self.cols, self.rows), f) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /teleterm-web/src/views/page.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub(crate) fn render(model: &crate::model::Model) -> Vec> { 4 | let mut view = vec![seed::h1!["teleterm"]]; 5 | 6 | if let Some(username) = model.username() { 7 | view.push(seed::p!["logged in as ", username]); 8 | view.push(seed::button![ 9 | simple_ev(Ev::Click, crate::Msg::Logout), 10 | "logout" 11 | ]); 12 | } else { 13 | view.push(seed::p!["not logged in"]); 14 | } 15 | 16 | if model.logging_in() { 17 | if model.username().is_some() { 18 | view.push(seed::p!["loading..."]); 19 | } else { 20 | view.extend(super::login::render(model)) 21 | } 22 | } else if model.choosing() { 23 | view.extend(super::list::render(model)) 24 | } else if model.watching() { 25 | view.extend(super::watch::render(model)) 26 | } else { 27 | unreachable!() 28 | } 29 | 30 | view 31 | } 32 | -------------------------------------------------------------------------------- /teleterm-web/src/protocol.rs: -------------------------------------------------------------------------------- 1 | // it's possible we should just consider pulling the real protocol out into a 2 | // crate or something? but ideally in a way that doesn't require pulling in 3 | // tokio 4 | 5 | #[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] 6 | pub enum AuthType { 7 | Plain, 8 | RecurseCenter, 9 | } 10 | 11 | #[derive(Clone, Debug, serde::Deserialize)] 12 | pub(crate) enum Message { 13 | TerminalOutput { data: Vec }, 14 | Disconnected, 15 | Resize { size: Size }, 16 | } 17 | 18 | #[derive(Clone, Debug, serde::Deserialize)] 19 | pub(crate) struct Session { 20 | pub id: String, 21 | pub username: String, 22 | pub term_type: String, 23 | pub size: Size, 24 | pub idle_time: u32, 25 | pub title: String, 26 | pub watchers: u32, 27 | } 28 | 29 | #[derive(Clone, Debug, serde::Deserialize)] 30 | pub(crate) struct Size { 31 | pub rows: u16, 32 | pub cols: u16, 33 | } 34 | 35 | #[derive(Clone, Debug, serde::Deserialize)] 36 | pub(crate) struct LoginResponse { 37 | pub username: String, 38 | } 39 | -------------------------------------------------------------------------------- /teleterm/src/web/view.rs: -------------------------------------------------------------------------------- 1 | use handlebars::handlebars_helper; 2 | use lazy_static::lazy_static; 3 | use lazy_static_include::*; 4 | 5 | lazy_static_include::lazy_static_include_bytes!( 6 | pub INDEX_HTML_TMPL, 7 | "static/index.html.tmpl" 8 | ); 9 | lazy_static_include::lazy_static_include_bytes!( 10 | pub TELETERM_WEB_JS, 11 | "static/teleterm_web.js" 12 | ); 13 | lazy_static_include::lazy_static_include_bytes!( 14 | pub TELETERM_WEB_WASM, 15 | "static/teleterm_web_bg.wasm" 16 | ); 17 | lazy_static_include::lazy_static_include_bytes!( 18 | pub TELETERM_CSS, 19 | "static/teleterm.css" 20 | ); 21 | 22 | handlebars_helper!(json: |x: object| serde_json::to_string(x).unwrap()); 23 | 24 | pub const INDEX_HTML_TMPL_NAME: &str = "index"; 25 | lazy_static::lazy_static! { 26 | pub static ref HANDLEBARS: handlebars::Handlebars = { 27 | let mut handlebars = handlebars::Handlebars::new(); 28 | handlebars.register_helper("json", Box::new(json)); 29 | handlebars 30 | .register_template_string( 31 | INDEX_HTML_TMPL_NAME, 32 | String::from_utf8(INDEX_HTML_TMPL.to_vec()).unwrap(), 33 | ) 34 | .unwrap(); 35 | handlebars 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /teleterm/src/main.rs: -------------------------------------------------------------------------------- 1 | // XXX this is broken with ale 2 | // #![warn(clippy::cargo)] 3 | #![warn(clippy::pedantic)] 4 | #![warn(clippy::nursery)] 5 | #![allow(clippy::match_same_arms)] 6 | #![allow(clippy::missing_const_for_fn)] 7 | #![allow(clippy::multiple_crate_versions)] 8 | #![allow(clippy::non_ascii_literal)] 9 | #![allow(clippy::similar_names)] 10 | #![allow(clippy::single_match)] 11 | #![allow(clippy::single_match_else)] 12 | #![allow(clippy::too_many_arguments)] 13 | #![allow(clippy::too_many_lines)] 14 | #![allow(clippy::type_complexity)] 15 | 16 | const _DUMMY_DEPENDENCY: &str = include_str!("../Cargo.toml"); 17 | 18 | mod prelude; 19 | 20 | mod async_stdin; 21 | mod auth; 22 | mod client; 23 | mod cmd; 24 | mod config; 25 | mod dirs; 26 | mod error; 27 | mod key_reader; 28 | mod oauth; 29 | mod protocol; 30 | mod server; 31 | mod session_list; 32 | mod term; 33 | mod web; 34 | 35 | fn main() { 36 | dirs::Dirs::new().create_all().unwrap(); 37 | match crate::cmd::parse().and_then(|m| crate::cmd::run(&m)) { 38 | Ok(_) => {} 39 | Err(err) => { 40 | // we don't know if the log crate has been initialized yet 41 | eprintln!("{}", err); 42 | std::process::exit(1); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is Copyright (c) 2019 by Jesse Luehrs. 2 | 3 | This is free software, licensed under: 4 | 5 | The MIT (X11) License 6 | 7 | The MIT License 8 | 9 | Permission is hereby granted, free of charge, to any person 10 | obtaining a copy of this software and associated 11 | documentation files (the "Software"), to deal in the Software 12 | without restriction, including without limitation the rights to 13 | use, copy, modify, merge, publish, distribute, sublicense, 14 | and/or sell copies of the Software, and to permit persons to 15 | whom the Software is furnished to do so, subject to the 16 | following conditions: 17 | 18 | The above copyright notice and this permission notice shall 19 | be included in all copies or substantial portions of the 20 | Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT 23 | WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 24 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 | MERCHANTABILITY, FITNESS FOR A PARTICULAR 26 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT 27 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 28 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 30 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 31 | CONNECTION WITH THE SOFTWARE OR THE USE OR 32 | OTHER DEALINGS IN THE SOFTWARE. 33 | -------------------------------------------------------------------------------- /teleterm-web/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod model; 3 | mod prelude; 4 | mod protocol; 5 | mod views; 6 | mod ws; 7 | 8 | use crate::prelude::*; 9 | 10 | #[allow(clippy::large_enum_variant)] 11 | #[derive(Clone)] 12 | enum Msg { 13 | Login(String), 14 | LoggedIn(seed::fetch::ResponseDataResult), 15 | Refresh, 16 | List(seed::fetch::ResponseDataResult>), 17 | StartWatching(String), 18 | Watch(String, crate::ws::WebSocketEvent), 19 | StopWatching, 20 | Logout, 21 | LoggedOut(seed::fetch::FetchObject<()>), 22 | } 23 | 24 | fn after_mount( 25 | _url: Url, 26 | orders: &mut impl Orders, 27 | ) -> AfterMount { 28 | log::trace!("after_mount"); 29 | AfterMount::new(crate::model::Model::new( 30 | crate::config::Config::load(), 31 | orders, 32 | )) 33 | } 34 | 35 | fn update( 36 | msg: Msg, 37 | model: &mut crate::model::Model, 38 | orders: &mut impl Orders, 39 | ) { 40 | log::trace!("update"); 41 | model.update(msg, orders); 42 | } 43 | 44 | fn view(model: &crate::model::Model) -> impl View { 45 | log::trace!("view"); 46 | crate::views::page::render(model) 47 | } 48 | 49 | #[wasm_bindgen(start)] 50 | pub fn start() { 51 | console_log::init_with_level(log::Level::Debug).unwrap(); 52 | log::debug!("start"); 53 | seed::App::builder(update, view) 54 | .after_mount(after_mount) 55 | .build_and_start(); 56 | } 57 | -------------------------------------------------------------------------------- /teleterm/src/web/oauth.rs: -------------------------------------------------------------------------------- 1 | use gotham::state::FromState as _; 2 | use std::convert::TryFrom as _; 3 | 4 | #[derive( 5 | serde::Deserialize, 6 | gotham_derive::StateData, 7 | gotham_derive::StaticResponseExtender, 8 | )] 9 | pub struct PathParts { 10 | method: String, 11 | } 12 | 13 | #[derive( 14 | serde::Deserialize, 15 | gotham_derive::StateData, 16 | gotham_derive::StaticResponseExtender, 17 | )] 18 | pub struct QueryParams { 19 | code: String, 20 | } 21 | 22 | pub fn run( 23 | mut state: gotham::state::State, 24 | ) -> (gotham::state::State, hyper::Response) { 25 | let auth_type = { 26 | let path_parts = PathParts::borrow_from(&state); 27 | crate::protocol::AuthType::try_from(path_parts.method.as_str()) 28 | }; 29 | let auth_type = match auth_type { 30 | Ok(auth_type) => auth_type, 31 | Err(e) => { 32 | return ( 33 | state, 34 | hyper::Response::builder() 35 | .status(hyper::StatusCode::BAD_REQUEST) 36 | .body(hyper::Body::from(format!("{}", e))) 37 | .unwrap(), 38 | ); 39 | } 40 | }; 41 | let code = { 42 | let query_params = QueryParams::borrow_from(&state); 43 | query_params.code.clone() 44 | }; 45 | 46 | // TODO 47 | 48 | ( 49 | state, 50 | hyper::Response::builder() 51 | .status(hyper::StatusCode::FOUND) 52 | .header(hyper::header::LOCATION, "/") 53 | .body(hyper::Body::empty()) 54 | .unwrap(), 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /teleterm-web/src/views/sessions.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub(crate) fn render( 4 | sessions: &[crate::protocol::Session], 5 | ) -> Node { 6 | let rows: Vec<_> = sessions.iter().map(row).collect(); 7 | seed::table![ 8 | seed::attrs! { At::Class => "list" }, 9 | seed::tr![ 10 | seed::th!["username"], 11 | seed::th!["size"], 12 | seed::th!["idle"], 13 | seed::th!["watchers"], 14 | seed::th!["title"], 15 | ], 16 | rows 17 | ] 18 | } 19 | 20 | fn row(session: &crate::protocol::Session) -> Node { 21 | seed::tr![ 22 | simple_ev(Ev::Click, crate::Msg::StartWatching(session.id.clone())), 23 | seed::td![seed::a![seed::attrs! {At::Href => "#"}, session.username]], 24 | seed::td![format!("{}x{}", session.size.cols, session.size.rows)], 25 | seed::td![format_time(session.idle_time)], 26 | seed::td![format!("{}", session.watchers)], 27 | seed::td![session.title], 28 | ] 29 | } 30 | 31 | // XXX copied from teleterm 32 | fn format_time(dur: u32) -> String { 33 | let secs = dur % 60; 34 | let dur = dur / 60; 35 | if dur == 0 { 36 | return format!("{}s", secs); 37 | } 38 | 39 | let mins = dur % 60; 40 | let dur = dur / 60; 41 | if dur == 0 { 42 | return format!("{}m{:02}s", mins, secs); 43 | } 44 | 45 | let hours = dur % 24; 46 | let dur = dur / 24; 47 | if dur == 0 { 48 | return format!("{}h{:02}m{:02}s", hours, mins, secs); 49 | } 50 | 51 | let days = dur; 52 | format!("{}d{:02}h{:02}m{:02}s", days, hours, mins, secs) 53 | } 54 | -------------------------------------------------------------------------------- /teleterm/src/auth/recurse_center.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub fn oauth_config( 4 | client_id: &str, 5 | client_secret: &str, 6 | redirect_url: &url::Url, 7 | ) -> crate::oauth::Config { 8 | crate::oauth::Config::new( 9 | client_id.to_string(), 10 | client_secret.to_string(), 11 | url::Url::parse("https://www.recurse.com/oauth/authorize").unwrap(), 12 | url::Url::parse("https://www.recurse.com/oauth/token").unwrap(), 13 | redirect_url.clone(), 14 | ) 15 | } 16 | 17 | pub fn get_username( 18 | access_token: &str, 19 | ) -> Box + Send> { 20 | let fut = reqwest::r#async::Client::new() 21 | .get("https://www.recurse.com/api/v1/profiles/me") 22 | .bearer_auth(access_token) 23 | .send() 24 | .context(crate::error::GetRecurseCenterProfile) 25 | .and_then(|mut res| res.json().context(crate::error::ParseJson)) 26 | .map(|user: User| user.name()); 27 | Box::new(fut) 28 | } 29 | 30 | #[derive(serde::Deserialize)] 31 | struct User { 32 | name: String, 33 | stints: Vec, 34 | } 35 | 36 | #[derive(serde::Deserialize)] 37 | struct Stint { 38 | batch: Option, 39 | start_date: String, 40 | } 41 | 42 | #[derive(serde::Deserialize)] 43 | struct Batch { 44 | short_name: String, 45 | } 46 | 47 | impl User { 48 | fn name(&self) -> String { 49 | let latest_stint = 50 | self.stints.iter().max_by_key(|s| &s.start_date).unwrap(); 51 | if let Some(batch) = &latest_stint.batch { 52 | format!("{} ({})", self.name, batch.short_name) 53 | } else { 54 | self.name.to_string() 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /teleterm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "teleterm" 3 | version = "0.2.0" 4 | authors = ["Jesse Luehrs "] 5 | edition = "2018" 6 | 7 | description = "share your terminals!" 8 | repository = "https://git.tozt.net/teleterm" 9 | readme = "README.md" 10 | keywords = ["terminal", "streaming"] 11 | categories = ["command-line-utilities"] 12 | license = "MIT" 13 | 14 | [dependencies] 15 | base64 = "0.11" 16 | bytes = "0.4" 17 | clap = { version = "2", features = ["wrap_help"] } 18 | component-future = "0.1" 19 | config = { version = "0.9", features = ["toml"], default_features = false } 20 | crossterm = "0.13" 21 | directories = "2" 22 | env_logger = "0.7" 23 | futures = "0.1.29" 24 | # for websocket support - should be able to go back to released version in 0.5 25 | gotham = { git = "https://github.com/gotham-rs/gotham", rev = "d2395926b93710832f8d72b49c9bd3e77516e386" } 26 | gotham_derive = "0.4" 27 | handlebars = "2" 28 | hyper = "0.12" 29 | lazy_static = "1" 30 | lazy-static-include = "2" 31 | log = { version = "0.4", features = ["release_max_level_info"] } 32 | mio = "0.6.19" 33 | native-tls = "0.2" 34 | oauth2 = { version = "=3.0.0-alpha.6", features = ["futures-01"] } # need the alpha for async support 35 | open = "1.1" 36 | rand = "0.7" 37 | ratelimit_meter = "5" 38 | regex = "1" 39 | reqwest = "0.9.22" 40 | serde = "1" 41 | serde_json = "1" 42 | sha1 = "0.6" 43 | snafu = { version = "0.6", features = ["futures-01"] } 44 | tokio = "0.1.22" 45 | tokio-pty-process-stream = "0.2" 46 | tokio-terminal-resize = "0.1" 47 | tokio-tls = "0.2" 48 | tokio-tungstenite = "0.9" 49 | ttyrec = "0.2" 50 | url = "2" 51 | users = "0.9" 52 | uuid = { version = "0.8", features = ["v4"] } 53 | vt100 = "0.8" 54 | 55 | [[bin]] 56 | name = "tt" 57 | path = "src/main.rs" 58 | 59 | [package.metadata.deb] 60 | depends = "openssl" 61 | -------------------------------------------------------------------------------- /teleterm-web/src/views/login.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub(crate) fn render(model: &crate::model::Model) -> Vec> { 4 | let plain = model.allowed_login_method(crate::protocol::AuthType::Plain); 5 | let recurse_center_url = if model 6 | .allowed_login_method(crate::protocol::AuthType::RecurseCenter) 7 | { 8 | model.oauth_login_url(crate::protocol::AuthType::RecurseCenter) 9 | } else { 10 | None 11 | }; 12 | 13 | let mut view = vec![]; 14 | 15 | if plain { 16 | view.extend(render_plain()); 17 | } 18 | if plain && recurse_center_url.is_some() { 19 | view.push(seed::p!["or"]) 20 | } 21 | if let Some(url) = recurse_center_url { 22 | view.extend(render_recurse_center(&url)); 23 | } 24 | 25 | view 26 | } 27 | 28 | fn render_plain() -> Vec> { 29 | vec![seed::form![ 30 | seed::label![seed::attrs! { At::For => "username" }, "username"], 31 | seed::input![seed::attrs! { 32 | At::Id => "username", 33 | At::Type => "text", 34 | At::AutoFocus => true.as_at_value(), 35 | }], 36 | seed::input![ 37 | seed::attrs! { At::Type => "submit", At::Value => "login" } 38 | ], 39 | raw_ev(Ev::Submit, |event| { 40 | event.prevent_default(); 41 | let username = seed::to_input( 42 | &seed::document().get_element_by_id("username").unwrap(), 43 | ) 44 | .value(); 45 | crate::Msg::Login(username) 46 | }), 47 | ]] 48 | } 49 | 50 | fn render_recurse_center(url: &str) -> Vec> { 51 | vec![seed::a![ 52 | seed::attrs! { 53 | At::Href => url, 54 | }, 55 | "login via oauth" 56 | ]] 57 | } 58 | -------------------------------------------------------------------------------- /teleterm/src/web/ws.rs: -------------------------------------------------------------------------------- 1 | // from https://github.com/gotham-rs/gotham/blob/master/examples/websocket/src/main.rs 2 | 3 | use futures::Future as _; 4 | 5 | const PROTO_WEBSOCKET: &str = "websocket"; 6 | 7 | pub fn requested(headers: &hyper::HeaderMap) -> bool { 8 | headers.get(hyper::header::UPGRADE) 9 | == Some(&hyper::header::HeaderValue::from_static(PROTO_WEBSOCKET)) 10 | } 11 | 12 | pub fn accept( 13 | headers: &hyper::HeaderMap, 14 | body: hyper::Body, 15 | ) -> Result< 16 | ( 17 | hyper::Response, 18 | impl futures::Future< 19 | Item = tokio_tungstenite::WebSocketStream< 20 | hyper::upgrade::Upgraded, 21 | >, 22 | Error = hyper::Error, 23 | >, 24 | ), 25 | (), 26 | > { 27 | let res = response(headers)?; 28 | let ws = body.on_upgrade().map(|upgraded| { 29 | tokio_tungstenite::WebSocketStream::from_raw_socket( 30 | upgraded, 31 | tokio_tungstenite::tungstenite::protocol::Role::Server, 32 | None, 33 | ) 34 | }); 35 | 36 | Ok((res, ws)) 37 | } 38 | 39 | fn response( 40 | headers: &hyper::HeaderMap, 41 | ) -> Result, ()> { 42 | let key = headers.get(hyper::header::SEC_WEBSOCKET_KEY).ok_or(())?; 43 | 44 | Ok(hyper::Response::builder() 45 | .header(hyper::header::UPGRADE, PROTO_WEBSOCKET) 46 | .header(hyper::header::CONNECTION, "upgrade") 47 | .header( 48 | hyper::header::SEC_WEBSOCKET_ACCEPT, 49 | accept_key(key.as_bytes()), 50 | ) 51 | .status(hyper::StatusCode::SWITCHING_PROTOCOLS) 52 | .body(hyper::Body::empty()) 53 | .unwrap()) 54 | } 55 | 56 | fn accept_key(key: &[u8]) -> String { 57 | const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; 58 | let mut sha1 = sha1::Sha1::default(); 59 | sha1.update(key); 60 | sha1.update(WS_GUID); 61 | base64::encode(&sha1.digest().bytes()) 62 | } 63 | -------------------------------------------------------------------------------- /teleterm-web/src/ws.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use wasm_bindgen::JsCast as _; 3 | 4 | #[derive(Clone)] 5 | pub(crate) enum WebSocketEvent { 6 | Connected(JsValue), 7 | Disconnected(JsValue), 8 | Message(MessageEvent), 9 | Error(ErrorEvent), 10 | } 11 | 12 | pub(crate) fn connect( 13 | url: &str, 14 | id: &str, 15 | msg: fn(String, WebSocketEvent) -> crate::Msg, 16 | orders: &mut impl Orders, 17 | ) -> WebSocket { 18 | let ws = WebSocket::new(url).unwrap(); 19 | 20 | register_ws_handler( 21 | id, 22 | WebSocket::set_onopen, 23 | WebSocketEvent::Connected, 24 | msg, 25 | &ws, 26 | orders, 27 | ); 28 | register_ws_handler( 29 | id, 30 | WebSocket::set_onclose, 31 | WebSocketEvent::Disconnected, 32 | msg, 33 | &ws, 34 | orders, 35 | ); 36 | register_ws_handler( 37 | id, 38 | WebSocket::set_onmessage, 39 | WebSocketEvent::Message, 40 | msg, 41 | &ws, 42 | orders, 43 | ); 44 | register_ws_handler( 45 | id, 46 | WebSocket::set_onerror, 47 | WebSocketEvent::Error, 48 | msg, 49 | &ws, 50 | orders, 51 | ); 52 | 53 | ws 54 | } 55 | 56 | fn register_ws_handler( 57 | id: &str, 58 | ws_cb_setter: fn(&WebSocket, Option<&js_sys::Function>), 59 | msg: F, 60 | ws_msg: fn(String, WebSocketEvent) -> crate::Msg, 61 | ws: &web_sys::WebSocket, 62 | orders: &mut impl Orders, 63 | ) where 64 | T: wasm_bindgen::convert::FromWasmAbi + 'static, 65 | F: Fn(T) -> WebSocketEvent + 'static, 66 | { 67 | let (app, msg_mapper) = (orders.clone_app(), orders.msg_mapper()); 68 | 69 | let id = id.to_string(); 70 | let closure = Closure::new(move |data| { 71 | app.update(msg_mapper(ws_msg(id.clone(), msg(data)))); 72 | }); 73 | 74 | ws_cb_setter(ws, Some(closure.as_ref().unchecked_ref())); 75 | closure.forget(); 76 | } 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Changed 6 | 7 | * Watch clients now receive resize events (although the terminal watch client 8 | just ignores them) 9 | 10 | ### Fixed 11 | 12 | * Streaming while using a terminal of size other than 80x24 works properly 13 | again. 14 | * Fixed a few more terminal parsing/drawing bugs. 15 | 16 | ## [0.2.0] - 2019-11-14 17 | 18 | ### Added 19 | 20 | * `tt play` now supports hiding the pause ui. 21 | 22 | ### Fixed 23 | 24 | * Bump `vt100` dep to fix a bunch of parsing bugs. 25 | * Now uses `vt100` to buffer data, removing the need for the `buffer_size` 26 | option, using less data overall, and hopefully fixing the inconsistencies 27 | when watching someone stream from a different terminal type. 28 | 29 | ## [0.1.6] - 2019-11-07 30 | 31 | ### Added 32 | 33 | * `tt play` now has key commands for seeking to the start or end of the file. 34 | * `tt play` now allows searching for frames whose contents match a regex. 35 | 36 | ## [0.1.5] - 2019-11-06 37 | 38 | ### Fixed 39 | 40 | * Fix clearing the screen in `tt watch`. 41 | 42 | ## [0.1.4] - 2019-11-06 43 | 44 | ### Added 45 | 46 | * `tt play` now supports seeking back and forth as well as pausing, adjusting 47 | the playback speed, and limiting the max amount of time each frame can take. 48 | 49 | ### Changed 50 | 51 | * Moved quite a lot of functionality out to separate crates - see 52 | `component-future`, `tokio-pty-process-stream`, `tokio-terminal-resize`, 53 | `ttyrec` 54 | 55 | ### Fixed 56 | 57 | * Ttyrecs with frame timestamps not starting at 0 can now be played properly. 58 | 59 | ## [0.1.3] - 2019-10-23 60 | 61 | ### Fixed 62 | 63 | * if a system user defines a home directory of `/`, treat it as not having a 64 | home directory 65 | 66 | ## [0.1.2] - 2019-10-23 67 | 68 | ### Fixed 69 | 70 | * set both the real and effective uid and gid instead of just effective when 71 | dropping privileges 72 | 73 | ## [0.1.1] - 2019-10-23 74 | 75 | ### Fixed 76 | 77 | * wait to drop privileges (via the `uid` and `gid` options until after we have 78 | read the `tls_identity_file`) 79 | 80 | ## [0.1.0] - 2019-10-23 81 | 82 | ### Added 83 | 84 | * Initial release 85 | -------------------------------------------------------------------------------- /teleterm/src/key_reader.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub struct KeyReader { 4 | events: Option< 5 | tokio::sync::mpsc::UnboundedReceiver, 6 | >, 7 | quit: Option>, 8 | } 9 | 10 | impl KeyReader { 11 | pub fn new() -> Self { 12 | Self { 13 | events: None, 14 | quit: None, 15 | } 16 | } 17 | } 18 | 19 | impl futures::Stream for KeyReader { 20 | type Item = crossterm::input::InputEvent; 21 | type Error = Error; 22 | 23 | fn poll(&mut self) -> futures::Poll, Self::Error> { 24 | if self.events.is_none() { 25 | let task = futures::task::current(); 26 | let reader = crossterm::input::input().read_sync(); 27 | let (events_tx, events_rx) = 28 | tokio::sync::mpsc::unbounded_channel(); 29 | let mut events_tx = events_tx.wait(); 30 | let (quit_tx, mut quit_rx) = tokio::sync::oneshot::channel(); 31 | // TODO: this is pretty janky - it'd be better to build in more 32 | // useful support to crossterm directly 33 | std::thread::Builder::new() 34 | .spawn(move || { 35 | for event in reader { 36 | // unwrap is unpleasant, but so is figuring out how to 37 | // propagate the error back to the main thread 38 | events_tx.send(event).unwrap(); 39 | task.notify(); 40 | if quit_rx.try_recv().is_ok() { 41 | break; 42 | } 43 | } 44 | }) 45 | .context(crate::error::TerminalInputReadingThread)?; 46 | 47 | self.events = Some(events_rx); 48 | self.quit = Some(quit_tx); 49 | } 50 | 51 | self.events 52 | .as_mut() 53 | .unwrap() 54 | .poll() 55 | .context(crate::error::ReadChannel) 56 | } 57 | } 58 | 59 | impl Drop for KeyReader { 60 | fn drop(&mut self) { 61 | if let Some(quit_tx) = self.quit.take() { 62 | // don't care if it fails to send, this can happen if the thread 63 | // terminates due to seeing a newline before the keyreader goes 64 | // out of scope 65 | let _ = quit_tx.send(()); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /teleterm/src/cmd/web.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | #[derive(serde::Deserialize, Debug, Default)] 4 | pub struct Config { 5 | #[serde(default)] 6 | web: crate::config::Web, 7 | 8 | #[serde( 9 | rename = "oauth", 10 | deserialize_with = "crate::config::oauth_configs", 11 | default 12 | )] 13 | oauth_configs: std::collections::HashMap< 14 | crate::protocol::AuthType, 15 | std::collections::HashMap< 16 | crate::protocol::AuthClient, 17 | crate::oauth::Config, 18 | >, 19 | >, 20 | } 21 | 22 | impl crate::config::Config for Config { 23 | fn merge_args<'a>( 24 | &mut self, 25 | matches: &clap::ArgMatches<'a>, 26 | ) -> Result<()> { 27 | self.web.merge_args(matches) 28 | } 29 | 30 | fn run( 31 | &self, 32 | ) -> Box + Send> { 33 | Box::new(crate::web::Server::new( 34 | self.web.listen_address, 35 | self.web.public_address.clone(), 36 | self.web.server_address.clone(), 37 | self.web.allowed_login_methods.clone(), 38 | self.oauth_configs 39 | .iter() 40 | .filter_map(|(ty, configs)| { 41 | configs.get(&crate::protocol::AuthClient::Web).map( 42 | |config| { 43 | let mut config = config.clone(); 44 | // TODO: tls 45 | let url = url::Url::parse(&format!( 46 | "http://{}/oauth/{}", 47 | self.web.public_address, 48 | ty.name() 49 | )) 50 | .unwrap(); 51 | config.set_redirect_url(url); 52 | (*ty, config) 53 | }, 54 | ) 55 | }) 56 | .collect(), 57 | )) 58 | } 59 | } 60 | 61 | pub fn cmd<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> { 62 | crate::config::Web::cmd(app.about("Run a teleterm web server")) 63 | } 64 | 65 | pub fn config( 66 | config: Option, 67 | ) -> Result> { 68 | let config: Config = if let Some(config) = config { 69 | config 70 | .try_into() 71 | .context(crate::error::CouldntParseConfig)? 72 | } else { 73 | Config::default() 74 | }; 75 | Ok(Box::new(config)) 76 | } 77 | -------------------------------------------------------------------------------- /teleterm/src/dirs.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub struct Dirs { 4 | project_dirs: Option, 5 | } 6 | 7 | impl Dirs { 8 | pub fn new() -> Self { 9 | Self { 10 | project_dirs: directories::ProjectDirs::from("", "", "teleterm"), 11 | } 12 | } 13 | 14 | pub fn create_all(&self) -> Result<()> { 15 | if let Some(filename) = self.data_dir() { 16 | std::fs::create_dir_all(filename).with_context(|| { 17 | crate::error::CreateDir { 18 | filename: filename.to_string_lossy(), 19 | } 20 | })?; 21 | } 22 | Ok(()) 23 | } 24 | 25 | fn has_home(&self) -> bool { 26 | directories::BaseDirs::new().map_or(false, |dirs| { 27 | dirs.home_dir() != std::path::Path::new("/") 28 | }) 29 | } 30 | 31 | fn global_config_dir(&self) -> &std::path::Path { 32 | std::path::Path::new("/etc/teleterm") 33 | } 34 | 35 | fn config_dir(&self) -> Option<&std::path::Path> { 36 | if self.has_home() { 37 | self.project_dirs 38 | .as_ref() 39 | .map(directories::ProjectDirs::config_dir) 40 | } else { 41 | None 42 | } 43 | } 44 | 45 | pub fn config_file( 46 | &self, 47 | name: &str, 48 | must_exist: bool, 49 | ) -> Option { 50 | if let Some(config_dir) = self.config_dir() { 51 | let file = config_dir.join(name); 52 | if !must_exist || file.exists() { 53 | return Some(file); 54 | } 55 | } 56 | 57 | let file = self.global_config_dir().join(name); 58 | if !must_exist || file.exists() { 59 | return Some(file); 60 | } 61 | 62 | None 63 | } 64 | 65 | fn global_data_dir(&self) -> &std::path::Path { 66 | std::path::Path::new("/var/lib/teleterm") 67 | } 68 | 69 | fn data_dir(&self) -> Option<&std::path::Path> { 70 | if self.has_home() { 71 | self.project_dirs 72 | .as_ref() 73 | .map(directories::ProjectDirs::data_dir) 74 | } else { 75 | None 76 | } 77 | } 78 | 79 | pub fn data_file( 80 | &self, 81 | name: &str, 82 | must_exist: bool, 83 | ) -> Option { 84 | if let Some(data_dir) = self.data_dir() { 85 | let file = data_dir.join(name); 86 | if !must_exist || file.exists() { 87 | return Some(file); 88 | } 89 | } 90 | 91 | let file = self.global_data_dir().join(name); 92 | if !must_exist || file.exists() { 93 | return Some(file); 94 | } 95 | 96 | None 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | these are things that i know need work (and i would welcome patches for), but 2 | i'm also interested in anything else you think would make this software more 3 | useful. 4 | 5 | * packages for more operating systems 6 | * homebrew is probably a big one? 7 | * grpc 8 | * although the existing hand-rolled protocol works fine, it does make it 9 | annoying to deal with things like sending the traffic through http 10 | proxies. pretty much the only reason this isn't using grpc is because i 11 | couldn't get any of the existing grpc crates to work reasonably, but 12 | that's likely more my fault than the fault of the crates themselves. 13 | * grpc would also make it much easier to enable compression (ideally we 14 | could just enable http compression at the library level), which is 15 | something that would be enormously helpful here (most terminal output is 16 | quite compressible) 17 | * block (server-side) and hide (client-side) functionality 18 | * it's pretty important for any social software 19 | * finish converting everything to async operations 20 | * dns lookups are still synchronous, which means that `tt stream` won't 21 | start up if you can't connect to the server (even though it's fine being 22 | able to lazily reconnect in the background) 23 | * a bunch of the stdout writing is still done synchronously 24 | * is thinking about the `log` stuff useful here? 25 | * once the standard library future stuff settles down, we probably want to 26 | use that (even though it's currently "stabilized", it doesn't look quite 27 | ready to build real things on yet) 28 | * integration tests 29 | * for instance, spin up separate server, stream, and watch subprocesses, 30 | and write tests for their stdout 31 | * key_reader could also use some tests, although it's a bit tricky - i 32 | basically want to be able to write a binary that uses key_reader, and 33 | have the test spawn that binary as a subprocess and write tests against 34 | that, but i'm not sure how to make cargo do that 35 | * watch ui improvements 36 | * should be able to sort by more things than just idle time 37 | * color more things (idle time colors might be useful, especially if 38 | support is added for different sorting methods) 39 | * get extended metadata about a stream somehow (maybe with shift+letter or 40 | something?) - things like names of watchers, etc 41 | * different authentication methods 42 | * adding new oauth providers should be pretty trivial 43 | * mtls/client cert auth would also be pretty useful 44 | * some kind of indication during streaming for when a watcher connects 45 | * visual bell should be pretty easy, but we might be able to be fancier by 46 | drawing a popup on the terminal somewhere, and just refreshing the screen 47 | from the buffer after some timeout 48 | * if we go the popup drawing route, it could also potentially be used for 49 | error message displays 50 | -------------------------------------------------------------------------------- /teleterm/src/async_stdin.rs: -------------------------------------------------------------------------------- 1 | struct EventedStdin; 2 | 3 | const STDIN: i32 = 0; 4 | 5 | impl std::io::Read for EventedStdin { 6 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 7 | let stdin = std::io::stdin(); 8 | let mut stdin = stdin.lock(); 9 | stdin.read(buf) 10 | } 11 | } 12 | 13 | impl mio::Evented for EventedStdin { 14 | fn register( 15 | &self, 16 | poll: &mio::Poll, 17 | token: mio::Token, 18 | interest: mio::Ready, 19 | opts: mio::PollOpt, 20 | ) -> std::io::Result<()> { 21 | let fd = STDIN as std::os::unix::io::RawFd; 22 | let eventedfd = mio::unix::EventedFd(&fd); 23 | eventedfd.register(poll, token, interest, opts) 24 | } 25 | 26 | fn reregister( 27 | &self, 28 | poll: &mio::Poll, 29 | token: mio::Token, 30 | interest: mio::Ready, 31 | opts: mio::PollOpt, 32 | ) -> std::io::Result<()> { 33 | let fd = STDIN as std::os::unix::io::RawFd; 34 | let eventedfd = mio::unix::EventedFd(&fd); 35 | eventedfd.reregister(poll, token, interest, opts) 36 | } 37 | 38 | fn deregister(&self, poll: &mio::Poll) -> std::io::Result<()> { 39 | let fd = STDIN as std::os::unix::io::RawFd; 40 | let eventedfd = mio::unix::EventedFd(&fd); 41 | eventedfd.deregister(poll) 42 | } 43 | } 44 | 45 | pub struct Stdin { 46 | input: tokio::reactor::PollEvented2, 47 | } 48 | 49 | impl Stdin { 50 | pub fn new() -> Self { 51 | Self { 52 | input: tokio::reactor::PollEvented2::new(EventedStdin), 53 | } 54 | } 55 | } 56 | 57 | impl std::io::Read for Stdin { 58 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 59 | self.input.read(buf) 60 | } 61 | } 62 | 63 | impl tokio::io::AsyncRead for Stdin { 64 | fn poll_read( 65 | &mut self, 66 | buf: &mut [u8], 67 | ) -> std::result::Result, tokio::io::Error> { 68 | // XXX this is why i had to do the EventedFd thing - poll_read on its 69 | // own will block reading from stdin, so i need a way to explicitly 70 | // check readiness before doing the read 71 | let ready = mio::Ready::readable(); 72 | match self.input.poll_read_ready(ready)? { 73 | futures::Async::Ready(_) => { 74 | let res = self.input.poll_read(buf); 75 | 76 | // XXX i'm pretty sure this is wrong (if the single poll_read 77 | // call didn't return all waiting data, clearing read ready 78 | // state means that we won't get the rest until some more data 79 | // beyond that appears), but i don't know that there's a way 80 | // to do it correctly given that poll_read blocks 81 | self.input.clear_read_ready(ready)?; 82 | 83 | res 84 | } 85 | futures::Async::NotReady => Ok(futures::Async::NotReady), 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /teleterm/src/web/disk_session.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use std::io::Write as _; 4 | 5 | #[derive(Clone)] 6 | pub struct DiskSession; 7 | 8 | impl DiskSession { 9 | fn file_for_id( 10 | &self, 11 | identifier: &gotham::middleware::session::SessionIdentifier, 12 | should_exist: bool, 13 | ) -> Option { 14 | let name = format!("web-{}", identifier.value); 15 | crate::dirs::Dirs::new().data_file(&name, should_exist) 16 | } 17 | } 18 | 19 | impl gotham::middleware::session::NewBackend for DiskSession { 20 | type Instance = Self; 21 | 22 | fn new_backend(&self) -> std::io::Result { 23 | Ok(Self) 24 | } 25 | } 26 | 27 | impl gotham::middleware::session::Backend for DiskSession { 28 | fn persist_session( 29 | &self, 30 | identifier: gotham::middleware::session::SessionIdentifier, 31 | content: &[u8], 32 | ) -> std::result::Result<(), gotham::middleware::session::SessionError> 33 | { 34 | let filename = self.file_for_id(&identifier, false).unwrap(); 35 | let mut file = std::fs::File::create(filename).map_err(|e| { 36 | gotham::middleware::session::SessionError::Backend(format!( 37 | "{}", 38 | e 39 | )) 40 | })?; 41 | file.write_all(content).map_err(|e| { 42 | gotham::middleware::session::SessionError::Backend(format!( 43 | "{}", 44 | e 45 | )) 46 | })?; 47 | file.sync_all().map_err(|e| { 48 | gotham::middleware::session::SessionError::Backend(format!( 49 | "{}", 50 | e 51 | )) 52 | })?; 53 | Ok(()) 54 | } 55 | 56 | fn read_session( 57 | &self, 58 | identifier: gotham::middleware::session::SessionIdentifier, 59 | ) -> Box< 60 | dyn futures::Future< 61 | Item = Option>, 62 | Error = gotham::middleware::session::SessionError, 63 | > + Send, 64 | > { 65 | if let Some(filename) = self.file_for_id(&identifier, true) { 66 | Box::new( 67 | tokio::fs::File::open(filename) 68 | .and_then(|file| { 69 | let buf = vec![]; 70 | tokio::io::read_to_end(file, buf) 71 | }) 72 | .map(|(_, v)| Some(v)) 73 | .map_err(|e| { 74 | gotham::middleware::session::SessionError::Backend( 75 | format!("{}", e), 76 | ) 77 | }), 78 | ) 79 | } else { 80 | Box::new(futures::future::ok(None)) 81 | } 82 | } 83 | 84 | fn drop_session( 85 | &self, 86 | identifier: gotham::middleware::session::SessionIdentifier, 87 | ) -> std::result::Result<(), gotham::middleware::session::SessionError> 88 | { 89 | if let Some(filename) = self.file_for_id(&identifier, true) { 90 | std::fs::remove_file(filename).map_err(|e| { 91 | gotham::middleware::session::SessionError::Backend(format!( 92 | "{}", 93 | e 94 | )) 95 | })?; 96 | } 97 | Ok(()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME = $(shell cargo metadata --no-deps --format-version 1 --manifest-path teleterm/Cargo.toml | jq -r '.name') 2 | VERSION = $(shell cargo metadata --no-deps --format-version 1 --manifest-path teleterm/Cargo.toml | jq -r '.version') 3 | 4 | INTERACTIVE_SUBCOMMANDS = stream watch record play 5 | NONINTERACTIVE_SUBCOMMANDS = server web 6 | SUBCOMMANDS = $(INTERACTIVE_SUBCOMMANDS) $(NONINTERACTIVE_SUBCOMMANDS) 7 | 8 | DEB_PACKAGE = $(NAME)_$(VERSION)_amd64.deb 9 | ARCH_PACKAGE = $(NAME)-$(VERSION)-1-x86_64.pkg.tar.xz 10 | 11 | all: 12 | @cargo build 13 | .PHONY: all 14 | 15 | release: 16 | @cargo build --release 17 | .PHONY: release 18 | 19 | test: 20 | @RUST_BACKTRACE=1 cargo test 21 | .PHONY: test 22 | 23 | check: 24 | @cargo check --all-targets 25 | .PHONY: check 26 | 27 | doc: 28 | @cargo doc --workspace 29 | .PHONY: doc 30 | 31 | $(SUBCOMMANDS): 32 | @RUST_BACKTRACE=1 cargo run $@ 33 | .PHONY: $(SUBCOMMANDS) 34 | 35 | $(NONINTERACTIVE_SUBCOMMANDS:%=d%): 36 | @RUST_LOG=tt=debug RUST_BACKTRACE=1 cargo run $$(echo $@ | sed 's/^d//') 37 | .PHONY: $(NONINTERACTIVE_SUBCOMMANDS:%=d%) 38 | 39 | $(INTERACTIVE_SUBCOMMANDS:%=d%): 40 | @echo "logging to $$(echo $@ | sed 's/^d//').log" 41 | @RUST_LOG=tt=debug RUST_BACKTRACE=1 cargo run $$(echo $@ | sed 's/^d//') 2>>$$(echo $@ | sed 's/^d//').log 42 | .PHONY: $(INTERACTIVE_SUBCOMMANDS:%=d%) 43 | 44 | $(SUBCOMMANDS:%=r%): 45 | @cargo run --release $$(echo $@ | sed 's/^r//') 46 | .PHONY: $(SUBCOMMANDS:%=r%) 47 | 48 | clean: 49 | @rm -rf *.log pkg 50 | .PHONY: clean 51 | 52 | cleanall: clean 53 | @cargo clean 54 | .PHONY: cleanall 55 | 56 | package: pkg/$(DEB_PACKAGE) pkg/$(ARCH_PACKAGE) 57 | .PHONY: package 58 | 59 | pkg: 60 | @mkdir pkg 61 | 62 | pkg/$(DEB_PACKAGE): | pkg 63 | @cargo deb && mv target/debian/$(DEB_PACKAGE) pkg 64 | 65 | pkg/$(DEB_PACKAGE).minisig: pkg/$(DEB_PACKAGE) 66 | @minisign -Sm pkg/$(DEB_PACKAGE) 67 | 68 | pkg/$(ARCH_PACKAGE): package/arch/PKGBUILD | pkg 69 | @cd package/arch && makepkg -c && mv $(ARCH_PACKAGE) ../../pkg 70 | 71 | pkg/$(ARCH_PACKAGE).minisig: pkg/$(ARCH_PACKAGE) 72 | @minisign -Sm pkg/$(ARCH_PACKAGE) 73 | 74 | release-dir-deb: 75 | @ssh tozt.net mkdir -p releases/teleterm/deb 76 | .PHONY: release-dir-deb 77 | 78 | publish: publish-crates-io publish-git-tags publish-deb publish-arch 79 | .PHONY: publish 80 | 81 | publish-crates-io: test 82 | @cargo publish 83 | .PHONY: publish-crates-io 84 | 85 | publish-git-tags: test 86 | @git tag $(VERSION) 87 | @git push --tags 88 | .PHONY: publish-git-tags 89 | 90 | publish-deb: test pkg/$(DEB_PACKAGE) pkg/$(DEB_PACKAGE).minisig release-dir-deb 91 | @scp pkg/$(DEB_PACKAGE) pkg/$(DEB_PACKAGE).minisig tozt.net:releases/teleterm/deb 92 | .PHONY: publish-deb 93 | 94 | release-dir-arch: 95 | @ssh tozt.net mkdir -p releases/teleterm/arch 96 | .PHONY: release-dir-arch 97 | 98 | publish-arch: test pkg/$(ARCH_PACKAGE) pkg/$(ARCH_PACKAGE).minisig release-dir-arch 99 | @scp pkg/$(ARCH_PACKAGE) pkg/$(ARCH_PACKAGE).minisig tozt.net:releases/teleterm/arch 100 | .PHONY: publish-arch 101 | 102 | install-arch: pkg/$(ARCH_PACKAGE) 103 | @sudo pacman -U pkg/$(ARCH_PACKAGE) 104 | .PHONY: install-arch 105 | 106 | wasm: teleterm/static/teleterm_web.js teleterm/static/teleterm_web_bg.wasm 107 | .PHONY: wasm 108 | 109 | web rweb dweb: wasm 110 | 111 | teleterm/static/teleterm_web_bg.wasm: target/wasm/teleterm_web_bg_opt.wasm 112 | @cp -f $< $@ 113 | 114 | teleterm/static/teleterm_web.js: target/wasm/teleterm_web_min.js 115 | @cp -f $< $@ 116 | 117 | target/wasm/%_opt.wasm: target/wasm/%.wasm 118 | @wasm-opt -Oz $< -o $@ 119 | 120 | target/wasm/%_min.js: target/wasm/%.js 121 | @terser $< > $@ 122 | 123 | target/wasm/teleterm_web.js target/wasm/teleterm_web_bg.wasm: teleterm-web/Cargo.toml teleterm-web/src/*.rs teleterm-web/src/views/*.rs 124 | @wasm-pack build --no-typescript --target web --out-dir ../target/wasm teleterm-web 125 | -------------------------------------------------------------------------------- /teleterm/src/cmd.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | mod play; 4 | mod record; 5 | mod server; 6 | mod stream; 7 | mod watch; 8 | mod web; 9 | 10 | struct Command { 11 | name: &'static str, 12 | cmd: &'static dyn for<'a, 'b> Fn(clap::App<'a, 'b>) -> clap::App<'a, 'b>, 13 | config: &'static dyn Fn( 14 | Option, 15 | ) -> Result>, 16 | log_level: &'static str, 17 | } 18 | 19 | const COMMANDS: &[Command] = &[ 20 | Command { 21 | name: "stream", 22 | cmd: &stream::cmd, 23 | config: &stream::config, 24 | log_level: "error", 25 | }, 26 | Command { 27 | name: "server", 28 | cmd: &server::cmd, 29 | config: &server::config, 30 | log_level: "info", 31 | }, 32 | Command { 33 | name: "web", 34 | cmd: &web::cmd, 35 | config: &web::config, 36 | log_level: "info", 37 | }, 38 | Command { 39 | name: "watch", 40 | cmd: &watch::cmd, 41 | config: &watch::config, 42 | log_level: "error", 43 | }, 44 | Command { 45 | name: "record", 46 | cmd: &record::cmd, 47 | config: &record::config, 48 | log_level: "error", 49 | }, 50 | Command { 51 | name: "play", 52 | cmd: &play::cmd, 53 | config: &play::config, 54 | log_level: "error", 55 | }, 56 | ]; 57 | 58 | pub fn parse<'a>() -> Result> { 59 | let mut app = clap::App::new(program_name()?) 60 | .about("Stream your terminal for other people to watch") 61 | .author(clap::crate_authors!()) 62 | .version(clap::crate_version!()) 63 | .arg( 64 | clap::Arg::with_name("config-file") 65 | .long("config-file") 66 | .takes_value(true) 67 | .value_name("FILE") 68 | .help("Read configuration from FILE"), 69 | ) 70 | .global_setting(clap::AppSettings::DontCollapseArgsInUsage) 71 | .global_setting(clap::AppSettings::GlobalVersion) 72 | .global_setting(clap::AppSettings::UnifiedHelpMessage) 73 | .global_setting(clap::AppSettings::VersionlessSubcommands); 74 | 75 | for cmd in COMMANDS { 76 | let subcommand = clap::SubCommand::with_name(cmd.name); 77 | app = app.subcommand( 78 | (cmd.cmd)(subcommand).setting(clap::AppSettings::NextLineHelp), 79 | ); 80 | } 81 | 82 | app.get_matches_safe().context(crate::error::ParseArgs) 83 | } 84 | 85 | pub fn run(matches: &clap::ArgMatches<'_>) -> Result<()> { 86 | let mut chosen_cmd = &COMMANDS[0]; 87 | let mut chosen_submatches = &clap::ArgMatches::<'_>::default(); 88 | for cmd in COMMANDS { 89 | if let Some(submatches) = matches.subcommand_matches(cmd.name) { 90 | chosen_cmd = cmd; 91 | chosen_submatches = submatches; 92 | } 93 | } 94 | 95 | env_logger::from_env( 96 | env_logger::Env::default().default_filter_or(chosen_cmd.log_level), 97 | ) 98 | .init(); 99 | 100 | let config = crate::config::config( 101 | matches.value_of("config-file").map(std::path::Path::new), 102 | )?; 103 | let mut cmd_config = (chosen_cmd.config)(config)?; 104 | cmd_config.merge_args(chosen_submatches)?; 105 | log::debug!("{:?}", cmd_config); 106 | 107 | // XXX ideally we'd be able to run everything on the current_thread 108 | // runtime, but this is blocked on 109 | // https://github.com/tokio-rs/tokio/issues/1356 (fixed in the 0.2 branch 110 | // which is not yet stable) 111 | 112 | // let mut runtime = tokio::runtime::current_thread::Runtime::new() 113 | // .unwrap(); 114 | // runtime 115 | // .block_on(cmd_config.run().map_err(|e| { 116 | // log::error!("{}", e); 117 | // })) 118 | // .unwrap(); 119 | 120 | tokio::run(cmd_config.run().map_err(|e| { 121 | log::error!("{}", e); 122 | })); 123 | 124 | Ok(()) 125 | } 126 | 127 | fn program_name() -> Result { 128 | let program = 129 | std::env::args().next().context(crate::error::MissingArgv)?; 130 | let path = std::path::Path::new(&program); 131 | let filename = path.file_name(); 132 | Ok(filename 133 | .ok_or_else(|| Error::NotAFileName { 134 | path: path.to_string_lossy().to_string(), 135 | })? 136 | .to_string_lossy() 137 | .to_string()) 138 | } 139 | -------------------------------------------------------------------------------- /teleterm/src/server/tls.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | pub struct Server { 4 | server: super::Server>, 5 | acceptor: Box< 6 | dyn futures::Stream< 7 | Item = tokio_tls::Accept, 8 | Error = Error, 9 | > + Send, 10 | >, 11 | sock_w: tokio::sync::mpsc::Sender< 12 | tokio_tls::TlsStream, 13 | >, 14 | accepting_sockets: Vec>, 15 | } 16 | 17 | impl Server { 18 | pub fn new( 19 | acceptor: Box< 20 | dyn futures::Stream< 21 | Item = tokio_tls::Accept, 22 | Error = Error, 23 | > + Send, 24 | >, 25 | read_timeout: std::time::Duration, 26 | allowed_login_methods: std::collections::HashSet< 27 | crate::protocol::AuthType, 28 | >, 29 | oauth_configs: std::collections::HashMap< 30 | crate::protocol::AuthType, 31 | crate::oauth::Config, 32 | >, 33 | ) -> Self { 34 | let (tls_sock_w, tls_sock_r) = tokio::sync::mpsc::channel(100); 35 | Self { 36 | server: super::Server::new( 37 | Box::new( 38 | tls_sock_r.context(crate::error::SocketChannelReceive), 39 | ), 40 | read_timeout, 41 | allowed_login_methods, 42 | oauth_configs, 43 | ), 44 | acceptor, 45 | sock_w: tls_sock_w, 46 | accepting_sockets: vec![], 47 | } 48 | } 49 | } 50 | 51 | impl Server { 52 | const POLL_FNS: 53 | &'static [&'static dyn for<'a> Fn( 54 | &'a mut Self, 55 | ) 56 | -> component_future::Poll< 57 | (), 58 | Error, 59 | >] = &[ 60 | &Self::poll_accept, 61 | &Self::poll_handshake_connections, 62 | &Self::poll_server, 63 | ]; 64 | 65 | fn poll_accept(&mut self) -> component_future::Poll<(), Error> { 66 | if let Some(sock) = component_future::try_ready!(self.acceptor.poll()) 67 | { 68 | self.accepting_sockets.push(sock); 69 | Ok(component_future::Async::DidWork) 70 | } else { 71 | Err(Error::SocketChannelClosed) 72 | } 73 | } 74 | 75 | fn poll_handshake_connections( 76 | &mut self, 77 | ) -> component_future::Poll<(), Error> { 78 | let mut did_work = false; 79 | let mut not_ready = false; 80 | 81 | let mut i = 0; 82 | while i < self.accepting_sockets.len() { 83 | let sock = self.accepting_sockets.get_mut(i).unwrap(); 84 | match sock.poll() { 85 | Ok(futures::Async::Ready(sock)) => { 86 | self.accepting_sockets.swap_remove(i); 87 | self.sock_w.try_send(sock).unwrap_or_else(|e| { 88 | log::warn!( 89 | "failed to send connected tls socket: {}", 90 | e 91 | ); 92 | }); 93 | did_work = true; 94 | continue; 95 | } 96 | Ok(futures::Async::NotReady) => { 97 | not_ready = true; 98 | } 99 | Err(e) => { 100 | log::warn!("failed to accept tls connection: {}", e); 101 | self.accepting_sockets.swap_remove(i); 102 | continue; 103 | } 104 | } 105 | i += 1; 106 | } 107 | 108 | if did_work { 109 | Ok(component_future::Async::DidWork) 110 | } else if not_ready { 111 | Ok(component_future::Async::NotReady) 112 | } else { 113 | Ok(component_future::Async::NothingToDo) 114 | } 115 | } 116 | 117 | fn poll_server(&mut self) -> component_future::Poll<(), Error> { 118 | component_future::try_ready!(self.server.poll()); 119 | Ok(component_future::Async::Ready(())) 120 | } 121 | } 122 | 123 | #[must_use = "futures do nothing unless polled"] 124 | impl futures::Future for Server { 125 | type Item = (); 126 | type Error = Error; 127 | 128 | fn poll(&mut self) -> futures::Poll { 129 | component_future::poll_future(self, Self::POLL_FNS) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /teleterm/src/web/list.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use gotham::state::FromState as _; 4 | 5 | pub fn run( 6 | state: gotham::state::State, 7 | ) -> (gotham::state::State, hyper::Response) { 8 | let session = gotham::middleware::session::SessionData::< 9 | crate::web::SessionData, 10 | >::borrow_from(&state); 11 | let auth = if let Some(login) = &session.login { 12 | &login.auth 13 | } else { 14 | return ( 15 | state, 16 | hyper::Response::builder() 17 | .status(hyper::StatusCode::FORBIDDEN) 18 | .body(hyper::Body::empty()) 19 | .unwrap(), 20 | ); 21 | }; 22 | 23 | let config = crate::web::Config::borrow_from(&state); 24 | 25 | let (_, address) = config.server_address; 26 | let connector: crate::client::Connector<_> = Box::new(move || { 27 | Box::new( 28 | tokio::net::tcp::TcpStream::connect(&address) 29 | .context(crate::error::Connect { address }), 30 | ) 31 | }); 32 | let client = crate::client::Client::raw( 33 | "teleterm-web", 34 | connector, 35 | auth, 36 | crate::protocol::AuthClient::Web, 37 | ); 38 | 39 | let (w_sessions, r_sessions) = tokio::sync::oneshot::channel(); 40 | 41 | tokio::spawn( 42 | Client::new(client, w_sessions) 43 | .map_err(|e| log::warn!("error listing: {}", e)), 44 | ); 45 | 46 | match r_sessions.wait().unwrap() { 47 | Ok(sessions) => { 48 | let body = serde_json::to_string(&sessions).unwrap(); 49 | (state, hyper::Response::new(hyper::Body::from(body))) 50 | } 51 | Err(e) => { 52 | log::warn!("error retrieving sessions: {}", e); 53 | ( 54 | state, 55 | hyper::Response::new(hyper::Body::from(format!( 56 | "error retrieving sessions: {}", 57 | e 58 | ))), 59 | ) 60 | } 61 | } 62 | } 63 | 64 | struct Client< 65 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 66 | > { 67 | client: crate::client::Client, 68 | w_sessions: Option< 69 | tokio::sync::oneshot::Sender>>, 70 | >, 71 | } 72 | 73 | impl 74 | Client 75 | { 76 | fn new( 77 | client: crate::client::Client, 78 | w_sessions: tokio::sync::oneshot::Sender< 79 | Result>, 80 | >, 81 | ) -> Self { 82 | Self { 83 | client, 84 | w_sessions: Some(w_sessions), 85 | } 86 | } 87 | 88 | fn server_message( 89 | &mut self, 90 | msg: crate::protocol::Message, 91 | ) -> Option>> { 92 | match msg { 93 | crate::protocol::Message::Sessions { sessions } => { 94 | Some(Ok(sessions)) 95 | } 96 | crate::protocol::Message::Disconnected => { 97 | Some(Err(Error::ServerDisconnected)) 98 | } 99 | crate::protocol::Message::Error { msg } => { 100 | Some(Err(Error::Server { message: msg })) 101 | } 102 | crate::protocol::Message::LoggedIn { .. } => { 103 | self.client 104 | .send_message(crate::protocol::Message::list_sessions()); 105 | None 106 | } 107 | msg => Some(Err(crate::error::Error::UnexpectedMessage { 108 | message: msg, 109 | })), 110 | } 111 | } 112 | } 113 | 114 | impl 115 | Client 116 | { 117 | const POLL_FNS: 118 | &'static [&'static dyn for<'a> Fn( 119 | &'a mut Self, 120 | ) 121 | -> component_future::Poll< 122 | (), 123 | Error, 124 | >] = &[&Self::poll_client]; 125 | 126 | fn poll_client(&mut self) -> component_future::Poll<(), Error> { 127 | match component_future::try_ready!(self.client.poll()).unwrap() { 128 | crate::client::Event::ServerMessage(msg) => { 129 | if let Some(res) = self.server_message(msg) { 130 | self.w_sessions.take().unwrap().send(res).unwrap(); 131 | return Ok(component_future::Async::Ready(())); 132 | } 133 | } 134 | _ => unreachable!(), 135 | } 136 | Ok(component_future::Async::DidWork) 137 | } 138 | } 139 | 140 | impl 141 | futures::Future for Client 142 | { 143 | type Item = (); 144 | type Error = Error; 145 | 146 | fn poll(&mut self) -> futures::Poll { 147 | component_future::poll_future(self, Self::POLL_FNS) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /teleterm/src/config/wizard.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::io::Write as _; 3 | 4 | pub fn run() -> Result> { 5 | println!("No configuration file found."); 6 | let run_wizard = prompt( 7 | "Would you like me to ask you some questions to generate one?", 8 | )?; 9 | if !run_wizard { 10 | let shouldnt_touch = prompt( 11 | "Would you like me to ask this question again in the future?", 12 | )?; 13 | if !shouldnt_touch { 14 | touch_config_file()?; 15 | } 16 | return Ok(None); 17 | } 18 | 19 | let connect_address = 20 | prompt_addr("Which server would you like to connect to?")?; 21 | let tls = prompt("Does this server require a TLS connection?")?; 22 | let auth_type = prompt_auth_type( 23 | "How would you like to authenticate to this server?", 24 | )?; 25 | 26 | write_config_file(&connect_address, tls, &auth_type).and_then( 27 | |config_filename| { 28 | Some(super::config_from_filename(&config_filename)).transpose() 29 | }, 30 | ) 31 | } 32 | 33 | fn touch_config_file() -> Result<()> { 34 | let config_filename = crate::dirs::Dirs::new() 35 | .config_file(super::CONFIG_FILENAME, false) 36 | .unwrap(); 37 | std::fs::File::create(config_filename.clone()).context( 38 | crate::error::CreateFileSync { 39 | filename: config_filename.to_string_lossy(), 40 | }, 41 | )?; 42 | Ok(()) 43 | } 44 | 45 | fn write_config_file( 46 | connect_address: &str, 47 | tls: bool, 48 | auth_type: &str, 49 | ) -> Result { 50 | let contents = format!( 51 | r#"[client] 52 | connect_address = "{}" 53 | tls = {} 54 | auth = "{}" 55 | "#, 56 | connect_address, tls, auth_type 57 | ); 58 | let config_filename = crate::dirs::Dirs::new() 59 | .config_file(super::CONFIG_FILENAME, false) 60 | .unwrap(); 61 | let mut file = std::fs::File::create(config_filename.clone()).context( 62 | crate::error::CreateFileSync { 63 | filename: config_filename.to_string_lossy(), 64 | }, 65 | )?; 66 | file.write_all(contents.as_bytes()) 67 | .context(crate::error::WriteFileSync)?; 68 | Ok(config_filename) 69 | } 70 | 71 | fn prompt(msg: &str) -> Result { 72 | print!("{} [y/n]: ", msg); 73 | std::io::stdout() 74 | .flush() 75 | .context(crate::error::FlushTerminal)?; 76 | let mut response = String::new(); 77 | std::io::stdin() 78 | .read_line(&mut response) 79 | .context(crate::error::ReadTerminal)?; 80 | 81 | loop { 82 | match response.trim() { 83 | "y" | "yes" => { 84 | return Ok(true); 85 | } 86 | "n" | "no" => { 87 | return Ok(false); 88 | } 89 | _ => { 90 | print!("Please answer [y]es or [n]o: "); 91 | std::io::stdout() 92 | .flush() 93 | .context(crate::error::FlushTerminal)?; 94 | std::io::stdin() 95 | .read_line(&mut response) 96 | .context(crate::error::ReadTerminal)?; 97 | } 98 | } 99 | } 100 | } 101 | 102 | fn prompt_addr(msg: &str) -> Result { 103 | loop { 104 | print!("{} [addr:port]: ", msg); 105 | std::io::stdout() 106 | .flush() 107 | .context(crate::error::FlushTerminal)?; 108 | let mut response = String::new(); 109 | std::io::stdin() 110 | .read_line(&mut response) 111 | .context(crate::error::ReadTerminal)?; 112 | 113 | match response.trim() { 114 | addr if addr.contains(':') => { 115 | match super::to_connect_address(addr) { 116 | Ok(..) => return Ok(addr.to_string()), 117 | _ => { 118 | println!("Couldn't parse '{}'.", addr); 119 | } 120 | }; 121 | } 122 | _ => { 123 | println!("Please include a port number."); 124 | } 125 | } 126 | } 127 | } 128 | 129 | fn prompt_auth_type(msg: &str) -> Result { 130 | let auth_type_names: Vec<_> = crate::protocol::AuthType::iter() 131 | .map(crate::protocol::AuthType::name) 132 | .collect(); 133 | 134 | loop { 135 | println!("{}", msg); 136 | println!("Options are:"); 137 | for (i, name) in auth_type_names.iter().enumerate() { 138 | println!("{}: {}", i + 1, name); 139 | } 140 | print!("Choose [1-{}]: ", auth_type_names.len()); 141 | std::io::stdout() 142 | .flush() 143 | .context(crate::error::FlushTerminal)?; 144 | let mut response = String::new(); 145 | std::io::stdin() 146 | .read_line(&mut response) 147 | .context(crate::error::ReadTerminal)?; 148 | 149 | let num: Option = response.trim().parse().ok(); 150 | if let Some(num) = num { 151 | if num > 0 && num <= auth_type_names.len() { 152 | let name = auth_type_names[num - 1]; 153 | return Ok(name.to_string()); 154 | } 155 | } 156 | 157 | println!("Invalid response '{}'", response.trim()); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /teleterm/src/web/login.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use gotham::handler::IntoHandlerError as _; 4 | use gotham::state::FromState as _; 5 | 6 | #[derive( 7 | serde::Deserialize, 8 | gotham_derive::StateData, 9 | gotham_derive::StaticResponseExtender, 10 | )] 11 | pub struct QueryParams { 12 | username: String, 13 | } 14 | 15 | #[derive(serde::Serialize)] 16 | struct Response { 17 | username: String, 18 | } 19 | 20 | pub fn run( 21 | mut state: gotham::state::State, 22 | ) -> Box< 23 | dyn futures::Future< 24 | Item = (gotham::state::State, hyper::Response), 25 | Error = (gotham::state::State, gotham::handler::HandlerError), 26 | > + Send, 27 | > { 28 | let username = { 29 | let query_params = QueryParams::borrow_from(&state); 30 | query_params.username.clone() 31 | }; 32 | 33 | let config = crate::web::Config::borrow_from(&state); 34 | 35 | let (_, address) = config.server_address; 36 | let connector: crate::client::Connector<_> = Box::new(move || { 37 | Box::new( 38 | tokio::net::tcp::TcpStream::connect(&address) 39 | .context(crate::error::Connect { address }), 40 | ) 41 | }); 42 | let auth = crate::protocol::Auth::plain(&username); 43 | let client = crate::client::Client::raw( 44 | "teleterm-web", 45 | connector, 46 | &auth, 47 | crate::protocol::AuthClient::Web, 48 | ); 49 | 50 | let (w_login, r_login) = tokio::sync::oneshot::channel(); 51 | 52 | tokio::spawn( 53 | Client::new(client, auth, w_login) 54 | // XXX if this happens, we might not have sent anything on the 55 | // channel, and so the wait might block forever 56 | .map_err(|e| log::error!("error logging in: {}", e)), 57 | ); 58 | 59 | Box::new(r_login.then(|res| { 60 | let session = gotham::middleware::session::SessionData::< 61 | crate::web::SessionData, 62 | >::borrow_mut_from(&mut state); 63 | match res { 64 | Ok(login) => { 65 | let session = gotham::middleware::session::SessionData::< 66 | crate::web::SessionData, 67 | >::borrow_mut_from(&mut state); 68 | 69 | match login { 70 | Ok(login) => { 71 | session.login = Some(login); 72 | futures::future::ok(( 73 | state, 74 | hyper::Response::new(hyper::Body::from( 75 | serde_json::to_string(&Response { username }) 76 | .unwrap(), 77 | )), 78 | )) 79 | } 80 | Err(e) => { 81 | session.login = None; 82 | log::error!("error logging in: {}", e); 83 | futures::future::err(( 84 | state, 85 | e.into_handler_error().with_status( 86 | hyper::StatusCode::INTERNAL_SERVER_ERROR, 87 | ), 88 | )) 89 | } 90 | } 91 | } 92 | Err(e) => { 93 | session.login = None; 94 | log::error!("error logging in: {}", e); 95 | futures::future::err(( 96 | state, 97 | e.into_handler_error().with_status( 98 | hyper::StatusCode::INTERNAL_SERVER_ERROR, 99 | ), 100 | )) 101 | } 102 | } 103 | })) 104 | } 105 | 106 | pub(crate) struct Client< 107 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 108 | > { 109 | client: crate::client::Client, 110 | auth: crate::protocol::Auth, 111 | w_login: Option>>, 112 | } 113 | 114 | impl 115 | Client 116 | { 117 | pub(crate) fn new( 118 | client: crate::client::Client, 119 | auth: crate::protocol::Auth, 120 | w_login: tokio::sync::oneshot::Sender>, 121 | ) -> Self { 122 | Self { 123 | client, 124 | auth, 125 | w_login: Some(w_login), 126 | } 127 | } 128 | } 129 | 130 | impl 131 | Client 132 | { 133 | const POLL_FNS: 134 | &'static [&'static dyn for<'a> Fn( 135 | &'a mut Self, 136 | ) 137 | -> component_future::Poll< 138 | (), 139 | Error, 140 | >] = &[&Self::poll_client]; 141 | 142 | fn poll_client(&mut self) -> component_future::Poll<(), Error> { 143 | let res = 144 | match component_future::try_ready!(self.client.poll()).unwrap() { 145 | crate::client::Event::ServerMessage(msg) => match msg { 146 | crate::protocol::Message::Disconnected => { 147 | Err(Error::ServerDisconnected) 148 | } 149 | crate::protocol::Message::Error { msg } => { 150 | Err(Error::Server { message: msg }) 151 | } 152 | crate::protocol::Message::LoggedIn { username } => { 153 | Ok(super::LoginState { 154 | auth: self.auth.clone(), 155 | username, 156 | }) 157 | } 158 | _ => { 159 | return Ok(component_future::Async::DidWork); 160 | } 161 | }, 162 | _ => unreachable!(), 163 | }; 164 | self.w_login.take().unwrap().send(res).unwrap(); 165 | Ok(component_future::Async::Ready(())) 166 | } 167 | } 168 | 169 | impl 170 | futures::Future for Client 171 | { 172 | type Item = (); 173 | type Error = Error; 174 | 175 | fn poll(&mut self) -> futures::Poll { 176 | component_future::poll_future(self, Self::POLL_FNS) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /teleterm/src/cmd/server.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use std::io::Read as _; 3 | 4 | #[derive(serde::Deserialize, Debug, Default)] 5 | pub struct Config { 6 | #[serde(default)] 7 | server: crate::config::Server, 8 | 9 | #[serde( 10 | rename = "oauth", 11 | deserialize_with = "crate::config::oauth_configs", 12 | default 13 | )] 14 | oauth_configs: std::collections::HashMap< 15 | crate::protocol::AuthType, 16 | std::collections::HashMap< 17 | crate::protocol::AuthClient, 18 | crate::oauth::Config, 19 | >, 20 | >, 21 | } 22 | 23 | impl crate::config::Config for Config { 24 | fn merge_args<'a>( 25 | &mut self, 26 | matches: &clap::ArgMatches<'a>, 27 | ) -> Result<()> { 28 | self.server.merge_args(matches) 29 | } 30 | 31 | fn run( 32 | &self, 33 | ) -> Box + Send> { 34 | let oauth_configs = self 35 | .oauth_configs 36 | .iter() 37 | .filter_map(|(ty, configs)| { 38 | configs 39 | .get(&crate::protocol::AuthClient::Cli) 40 | .map(|config| (*ty, config.clone())) 41 | }) 42 | .collect(); 43 | if let Some(tls_identity_file) = &self.server.tls_identity_file { 44 | create_server_tls( 45 | self.server.listen_address, 46 | self.server.read_timeout, 47 | tls_identity_file, 48 | self.server.allowed_login_methods.clone(), 49 | oauth_configs, 50 | self.server.uid, 51 | self.server.gid, 52 | ) 53 | } else { 54 | create_server( 55 | self.server.listen_address, 56 | self.server.read_timeout, 57 | self.server.allowed_login_methods.clone(), 58 | oauth_configs, 59 | self.server.uid, 60 | self.server.gid, 61 | ) 62 | } 63 | } 64 | } 65 | 66 | pub fn cmd<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> { 67 | crate::config::Server::cmd(app.about("Run a teleterm server")) 68 | } 69 | 70 | pub fn config( 71 | config: Option, 72 | ) -> Result> { 73 | let config: Config = if let Some(config) = config { 74 | config 75 | .try_into() 76 | .context(crate::error::CouldntParseConfig)? 77 | } else { 78 | Config::default() 79 | }; 80 | Ok(Box::new(config)) 81 | } 82 | 83 | fn create_server( 84 | address: std::net::SocketAddr, 85 | read_timeout: std::time::Duration, 86 | allowed_login_methods: std::collections::HashSet< 87 | crate::protocol::AuthType, 88 | >, 89 | oauth_configs: std::collections::HashMap< 90 | crate::protocol::AuthType, 91 | crate::oauth::Config, 92 | >, 93 | uid: Option, 94 | gid: Option, 95 | ) -> Box + Send> { 96 | let listener = match listen(address, uid, gid) { 97 | Ok(listener) => listener, 98 | Err(e) => return Box::new(futures::future::err(e)), 99 | }; 100 | 101 | let acceptor = listener.incoming().context(crate::error::Acceptor); 102 | let server = crate::server::Server::new( 103 | Box::new(acceptor), 104 | read_timeout, 105 | allowed_login_methods, 106 | oauth_configs, 107 | ); 108 | 109 | Box::new(server) 110 | } 111 | 112 | fn create_server_tls( 113 | address: std::net::SocketAddr, 114 | read_timeout: std::time::Duration, 115 | tls_identity_file: &str, 116 | allowed_login_methods: std::collections::HashSet< 117 | crate::protocol::AuthType, 118 | >, 119 | oauth_configs: std::collections::HashMap< 120 | crate::protocol::AuthType, 121 | crate::oauth::Config, 122 | >, 123 | uid: Option, 124 | gid: Option, 125 | ) -> Box + Send> { 126 | let tls_acceptor = match accept_tls(tls_identity_file) { 127 | Ok(acceptor) => acceptor, 128 | Err(e) => return Box::new(futures::future::err(e)), 129 | }; 130 | 131 | let listener = match listen(address, uid, gid) { 132 | Ok(listener) => listener, 133 | Err(e) => return Box::new(futures::future::err(e)), 134 | }; 135 | 136 | let acceptor = listener 137 | .incoming() 138 | .context(crate::error::Acceptor) 139 | .map(move |sock| tls_acceptor.accept(sock)); 140 | let server = crate::server::tls::Server::new( 141 | Box::new(acceptor), 142 | read_timeout, 143 | allowed_login_methods, 144 | oauth_configs, 145 | ); 146 | 147 | Box::new(server) 148 | } 149 | 150 | fn listen( 151 | address: std::net::SocketAddr, 152 | uid: Option, 153 | gid: Option, 154 | ) -> Result { 155 | let listener = tokio::net::TcpListener::bind(&address) 156 | .context(crate::error::Bind { address })?; 157 | drop_privs(uid, gid)?; 158 | log::info!("Listening on {}", address); 159 | Ok(listener) 160 | } 161 | 162 | fn accept_tls(tls_identity_file: &str) -> Result { 163 | let mut file = std::fs::File::open(tls_identity_file).context( 164 | crate::error::OpenFileSync { 165 | filename: tls_identity_file, 166 | }, 167 | )?; 168 | let mut identity = vec![]; 169 | file.read_to_end(&mut identity) 170 | .context(crate::error::ReadFileSync)?; 171 | let identity = native_tls::Identity::from_pkcs12(&identity, "") 172 | .context(crate::error::ParseIdentity)?; 173 | let acceptor = native_tls::TlsAcceptor::new(identity) 174 | .context(crate::error::CreateAcceptor)?; 175 | 176 | Ok(tokio_tls::TlsAcceptor::from(acceptor)) 177 | } 178 | 179 | fn drop_privs( 180 | uid: Option, 181 | gid: Option, 182 | ) -> Result<()> { 183 | if let Some(gid) = gid { 184 | users::switch::set_both_gid(gid, gid) 185 | .context(crate::error::SwitchGid)?; 186 | } 187 | if let Some(uid) = uid { 188 | users::switch::set_both_uid(uid, uid) 189 | .context(crate::error::SwitchUid)?; 190 | } 191 | Ok(()) 192 | } 193 | -------------------------------------------------------------------------------- /teleterm/src/oauth.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use oauth2::TokenResponse as _; 3 | 4 | // this needs to be fixed because we listen for it in a hardcoded place 5 | pub const CLI_REDIRECT_URL: &str = "http://localhost:44141/oauth"; 6 | 7 | pub struct Oauth { 8 | client: oauth2::basic::BasicClient, 9 | user_id: String, 10 | } 11 | 12 | impl Oauth { 13 | pub fn new(config: Config, user_id: String) -> Self { 14 | let client = config.into_basic_client(); 15 | Self { client, user_id } 16 | } 17 | 18 | pub fn generate_authorize_url(&self) -> String { 19 | let (auth_url, _) = self 20 | .client 21 | .authorize_url(oauth2::CsrfToken::new_random) 22 | .url(); 23 | auth_url.to_string() 24 | } 25 | 26 | pub fn user_id(&self) -> &str { 27 | &self.user_id 28 | } 29 | 30 | pub fn get_access_token_from_auth_code( 31 | &self, 32 | code: &str, 33 | ) -> Box + Send> { 34 | let token_cache_file = self.server_token_file(false).unwrap(); 35 | let fut = self 36 | .client 37 | .exchange_code(oauth2::AuthorizationCode::new(code.to_string())) 38 | .request_future(oauth2::reqwest::future_http_client) 39 | .map_err(|e| { 40 | let msg = stringify_oauth2_http_error(&e); 41 | Error::ExchangeCode { msg } 42 | }) 43 | .and_then(|token| { 44 | cache_refresh_token(token_cache_file, &token) 45 | .map(move |_| token.access_token().secret().to_string()) 46 | }); 47 | Box::new(fut) 48 | } 49 | 50 | pub fn get_access_token_from_refresh_token( 51 | self, 52 | ) -> Box + Send> { 53 | let token_cache_file = self.server_token_file(false).unwrap(); 54 | let fut = load_refresh_token(&token_cache_file).and_then( 55 | move |refresh_token| { 56 | // XXX 57 | let refresh_token = refresh_token.unwrap(); 58 | self.client 59 | .exchange_refresh_token(&oauth2::RefreshToken::new( 60 | refresh_token, 61 | )) 62 | .request_future(oauth2::reqwest::future_http_client) 63 | .map_err(|e| { 64 | let msg = stringify_oauth2_http_error(&e); 65 | Error::ExchangeRefreshToken { msg } 66 | }) 67 | .and_then(move |token| { 68 | cache_refresh_token(token_cache_file, &token).map( 69 | move |_| { 70 | token.access_token().secret().to_string() 71 | }, 72 | ) 73 | }) 74 | }, 75 | ); 76 | Box::new(fut) 77 | } 78 | 79 | pub fn server_token_file( 80 | &self, 81 | must_exist: bool, 82 | ) -> Option { 83 | let name = format!("server-oauth-{}", self.user_id); 84 | crate::dirs::Dirs::new().data_file(&name, must_exist) 85 | } 86 | } 87 | 88 | fn load_refresh_token( 89 | token_cache_file: &std::path::Path, 90 | ) -> Box, Error = Error> + Send> { 91 | let token_cache_file = token_cache_file.to_path_buf(); 92 | Box::new( 93 | tokio::fs::File::open(token_cache_file.clone()) 94 | .with_context(move || crate::error::OpenFile { 95 | filename: token_cache_file.to_string_lossy().to_string(), 96 | }) 97 | .and_then(|file| { 98 | tokio::io::lines(std::io::BufReader::new(file)) 99 | .into_future() 100 | .map_err(|(e, _)| e) 101 | .context(crate::error::ReadFile) 102 | }) 103 | .map(|(refresh_token, _)| refresh_token), 104 | ) 105 | } 106 | 107 | fn cache_refresh_token( 108 | token_cache_file: std::path::PathBuf, 109 | token: &oauth2::basic::BasicTokenResponse, 110 | ) -> Box + Send> { 111 | let token_data = format!( 112 | "{}\n{}\n", 113 | token.refresh_token().unwrap().secret(), 114 | token.access_token().secret(), 115 | ); 116 | let fut = tokio::fs::File::create(token_cache_file.clone()) 117 | .with_context(move || crate::error::CreateFile { 118 | filename: token_cache_file.to_string_lossy().to_string(), 119 | }) 120 | .and_then(|file| { 121 | tokio::io::write_all(file, token_data) 122 | .context(crate::error::WriteFile) 123 | }) 124 | .map(|_| ()); 125 | Box::new(fut) 126 | } 127 | 128 | #[derive(Debug, Clone)] 129 | pub struct Config { 130 | client_id: String, 131 | client_secret: String, 132 | auth_url: url::Url, 133 | token_url: url::Url, 134 | redirect_url: url::Url, 135 | } 136 | 137 | impl Config { 138 | pub fn new( 139 | client_id: String, 140 | client_secret: String, 141 | auth_url: url::Url, 142 | token_url: url::Url, 143 | redirect_url: url::Url, 144 | ) -> Self { 145 | Self { 146 | client_id, 147 | client_secret, 148 | auth_url, 149 | token_url, 150 | redirect_url, 151 | } 152 | } 153 | 154 | pub fn set_redirect_url(&mut self, url: url::Url) { 155 | self.redirect_url = url; 156 | } 157 | 158 | fn into_basic_client(self) -> oauth2::basic::BasicClient { 159 | oauth2::basic::BasicClient::new( 160 | oauth2::ClientId::new(self.client_id), 161 | Some(oauth2::ClientSecret::new(self.client_secret)), 162 | oauth2::AuthUrl::new(self.auth_url.to_string()).unwrap(), 163 | Some(oauth2::TokenUrl::new(self.token_url.to_string()).unwrap()), 164 | ) 165 | .set_redirect_url( 166 | oauth2::RedirectUrl::new(self.redirect_url.to_string()).unwrap(), 167 | ) 168 | } 169 | } 170 | 171 | // make this actually give useful information, because the default 172 | // stringification is pretty useless 173 | fn stringify_oauth2_http_error( 174 | e: &oauth2::RequestTokenError< 175 | oauth2::reqwest::Error, 176 | oauth2::StandardErrorResponse, 177 | >, 178 | ) -> String { 179 | match e { 180 | oauth2::RequestTokenError::ServerResponse(t) => { 181 | format!("ServerResponse({})", t) 182 | } 183 | oauth2::RequestTokenError::Request(re) => format!("Request({})", re), 184 | oauth2::RequestTokenError::Parse(se, b) => format!( 185 | "Parse({}, {})", 186 | se, 187 | std::string::String::from_utf8_lossy(b) 188 | ), 189 | oauth2::RequestTokenError::Other(s) => format!("Other({})", s), 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /teleterm/src/web.rs: -------------------------------------------------------------------------------- 1 | mod disk_session; 2 | mod list; 3 | mod login; 4 | mod logout; 5 | mod oauth; 6 | mod view; 7 | mod watch; 8 | mod ws; 9 | 10 | use crate::prelude::*; 11 | 12 | use gotham::router::builder::{DefineSingleRoute as _, DrawRoutes as _}; 13 | use gotham::state::FromState as _; 14 | 15 | #[derive(Clone, gotham_derive::StateData)] 16 | struct Config { 17 | server_address: (String, std::net::SocketAddr), 18 | public_address: String, 19 | allowed_login_methods: 20 | std::collections::HashSet, 21 | oauth_configs: std::collections::HashMap< 22 | crate::protocol::AuthType, 23 | crate::oauth::Config, 24 | >, 25 | } 26 | 27 | impl Config { 28 | fn allowed_oauth_login_methods( 29 | &self, 30 | ) -> impl Iterator + '_ { 31 | self.allowed_login_methods 32 | .iter() 33 | .copied() 34 | .filter(|ty| ty.is_oauth()) 35 | } 36 | } 37 | 38 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 39 | pub(crate) struct LoginState { 40 | auth: crate::protocol::Auth, 41 | username: String, 42 | } 43 | 44 | #[derive(Default, serde::Deserialize, serde::Serialize)] 45 | struct SessionData { 46 | login: Option, 47 | } 48 | 49 | #[derive(Debug, serde::Serialize)] 50 | struct WebConfig<'a> { 51 | username: Option<&'a str>, 52 | public_address: &'a str, 53 | allowed_login_methods: 54 | &'a std::collections::HashSet, 55 | oauth_login_urls: 56 | std::collections::HashMap, 57 | } 58 | 59 | impl<'a> WebConfig<'a> { 60 | fn new(config: &'a Config, session: &'a SessionData) -> Result { 61 | let mut oauth_login_urls = std::collections::HashMap::new(); 62 | for ty in config.allowed_oauth_login_methods() { 63 | let oauth_config = config 64 | .oauth_configs 65 | .get(&ty) 66 | .context(crate::error::AuthTypeMissingOauthConfig { ty })?; 67 | let client = ty.oauth_client(oauth_config, None).unwrap(); 68 | oauth_login_urls.insert(ty, client.generate_authorize_url()); 69 | } 70 | Ok(Self { 71 | username: session 72 | .login 73 | .as_ref() 74 | .map(|login| login.username.as_str()), 75 | public_address: &config.public_address, 76 | allowed_login_methods: &config.allowed_login_methods, 77 | oauth_login_urls, 78 | }) 79 | } 80 | } 81 | 82 | pub struct Server { 83 | server: Box + Send>, 84 | } 85 | 86 | impl Server { 87 | pub fn new( 88 | listen_address: std::net::SocketAddr, 89 | public_address: String, 90 | server_address: (String, std::net::SocketAddr), 91 | allowed_login_methods: std::collections::HashSet< 92 | crate::protocol::AuthType, 93 | >, 94 | oauth_configs: std::collections::HashMap< 95 | crate::protocol::AuthType, 96 | crate::oauth::Config, 97 | >, 98 | ) -> Self { 99 | let data = Config { 100 | server_address, 101 | public_address, 102 | allowed_login_methods, 103 | oauth_configs, 104 | }; 105 | Self { 106 | server: Box::new(gotham::init_server( 107 | listen_address, 108 | router(&data), 109 | )), 110 | } 111 | } 112 | } 113 | 114 | impl futures::Future for Server { 115 | type Item = (); 116 | type Error = Error; 117 | 118 | fn poll(&mut self) -> futures::Poll { 119 | self.server.poll().map_err(|_| unreachable!()) 120 | } 121 | } 122 | 123 | fn router(data: &Config) -> impl gotham::handler::NewHandler { 124 | let (chain, pipeline) = gotham::pipeline::single::single_pipeline( 125 | gotham::pipeline::new_pipeline() 126 | .add(gotham::middleware::state::StateMiddleware::new( 127 | data.clone(), 128 | )) 129 | .add( 130 | gotham::middleware::session::NewSessionMiddleware::new( 131 | disk_session::DiskSession, 132 | ) 133 | .insecure() 134 | .with_cookie_name("teleterm") 135 | .with_session_type::(), 136 | ) 137 | .build(), 138 | ); 139 | gotham::router::builder::build_router(chain, pipeline, |route| { 140 | route 141 | .get("/") 142 | .to(serve_template("text/html", view::INDEX_HTML_TMPL_NAME)); 143 | route.get("/teleterm_web.js").to(serve_static( 144 | "application/javascript", 145 | &view::TELETERM_WEB_JS, 146 | )); 147 | route 148 | .get("/teleterm_web_bg.wasm") 149 | .to(serve_static("application/wasm", &view::TELETERM_WEB_WASM)); 150 | route 151 | .get("/teleterm.css") 152 | .to(serve_static("text/css", &view::TELETERM_CSS)); 153 | route.get("/list").to(list::run); 154 | route 155 | .get("/watch") 156 | .with_query_string_extractor::() 157 | .to(watch::run); 158 | route 159 | .get("/login") 160 | .with_query_string_extractor::() 161 | .to(login::run); 162 | route 163 | .get("/oauth/:method") 164 | .with_path_extractor::() 165 | .with_query_string_extractor::() 166 | .to(oauth::run); 167 | route.get("/logout").to(logout::run); 168 | }) 169 | } 170 | 171 | fn serve_static( 172 | content_type: &'static str, 173 | s: &'static [u8], 174 | ) -> impl gotham::handler::Handler + Copy { 175 | move |state| { 176 | let response = hyper::Response::builder() 177 | .header("Content-Type", content_type) 178 | .body(hyper::Body::from(s)) 179 | .unwrap(); 180 | (state, response) 181 | } 182 | } 183 | 184 | fn serve_template( 185 | content_type: &'static str, 186 | name: &'static str, 187 | ) -> impl gotham::handler::Handler + Copy { 188 | move |state| { 189 | let config = Config::borrow_from(&state); 190 | let session = gotham::middleware::session::SessionData::< 191 | crate::web::SessionData, 192 | >::borrow_from(&state); 193 | let web_config = match WebConfig::new(config, session) { 194 | Ok(config) => config, 195 | Err(e) => { 196 | // this means that the server configuration is incorrect, and 197 | // there's nothing the client can do about it 198 | return ( 199 | state, 200 | hyper::Response::builder() 201 | .status(hyper::StatusCode::INTERNAL_SERVER_ERROR) 202 | .body(hyper::Body::from(format!("{}", e))) 203 | .unwrap(), 204 | ); 205 | } 206 | }; 207 | let rendered = view::HANDLEBARS.render(name, &web_config).unwrap(); 208 | let response = hyper::Response::builder() 209 | .header("Content-Type", content_type) 210 | .body(hyper::Body::from(rendered)) 211 | .unwrap(); 212 | (state, response) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /teleterm-web/src/model.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | struct WatchConn { 4 | ws: WebSocket, 5 | term: vt100::Parser, 6 | received_data: bool, 7 | } 8 | 9 | impl WatchConn { 10 | fn new(ws: WebSocket) -> Self { 11 | Self { 12 | ws, 13 | term: vt100::Parser::default(), 14 | received_data: false, 15 | } 16 | } 17 | } 18 | 19 | impl Drop for WatchConn { 20 | fn drop(&mut self) { 21 | self.ws.close().unwrap(); 22 | } 23 | } 24 | 25 | #[allow(clippy::large_enum_variant)] 26 | enum State { 27 | Login, 28 | List(Vec), 29 | Watch(WatchConn), 30 | } 31 | 32 | pub(crate) struct Model { 33 | config: crate::config::Config, 34 | state: State, 35 | } 36 | 37 | impl Model { 38 | pub(crate) fn new( 39 | config: crate::config::Config, 40 | orders: &mut impl Orders, 41 | ) -> Self { 42 | let logged_in = config.username.is_some(); 43 | let self_ = Self { 44 | config, 45 | state: State::Login, 46 | }; 47 | if logged_in { 48 | self_.list(orders); 49 | } 50 | self_ 51 | } 52 | 53 | pub(crate) fn update( 54 | &mut self, 55 | msg: crate::Msg, 56 | orders: &mut impl Orders, 57 | ) { 58 | match msg { 59 | crate::Msg::Login(username) => { 60 | log::debug!("login for username {}", username); 61 | self.login(&username, orders); 62 | } 63 | crate::Msg::LoggedIn(response) => match response { 64 | Ok(response) => { 65 | log::debug!("logged in as {}", response.username); 66 | self.config.username = Some(response.username); 67 | orders.send_msg(crate::Msg::Refresh); 68 | } 69 | Err(e) => { 70 | log::error!("error logging in: {:?}", e); 71 | } 72 | }, 73 | crate::Msg::Refresh => { 74 | log::debug!("refreshing"); 75 | self.list(orders); 76 | } 77 | crate::Msg::List(sessions) => match sessions { 78 | Ok(sessions) => { 79 | log::debug!("got sessions"); 80 | self.state = State::List(sessions); 81 | } 82 | Err(e) => { 83 | log::error!("error getting sessions: {:?}", e); 84 | } 85 | }, 86 | crate::Msg::StartWatching(id) => { 87 | log::debug!("watching {}", id); 88 | self.watch(&id, orders); 89 | } 90 | crate::Msg::Watch(id, event) => match event { 91 | crate::ws::WebSocketEvent::Connected(_) => { 92 | log::info!("{}: connected", id); 93 | } 94 | crate::ws::WebSocketEvent::Disconnected(_) => { 95 | log::info!("{}: disconnected", id); 96 | } 97 | crate::ws::WebSocketEvent::Message(msg) => { 98 | log::info!("{}: message: {:?}", id, msg); 99 | let json = msg.data().as_string().unwrap(); 100 | let msg: crate::protocol::Message = 101 | serde_json::from_str(&json).unwrap(); 102 | match msg { 103 | crate::protocol::Message::TerminalOutput { data } => { 104 | self.process(&data); 105 | } 106 | crate::protocol::Message::Disconnected => { 107 | self.list(orders); 108 | } 109 | crate::protocol::Message::Resize { size } => { 110 | self.set_size(size.rows, size.cols); 111 | } 112 | } 113 | } 114 | crate::ws::WebSocketEvent::Error(e) => { 115 | log::error!("{}: error: {:?}", id, e); 116 | } 117 | }, 118 | crate::Msg::StopWatching => { 119 | log::debug!("stop watching"); 120 | self.list(orders); 121 | } 122 | crate::Msg::Logout => { 123 | log::debug!("logout"); 124 | self.logout(orders); 125 | } 126 | crate::Msg::LoggedOut(..) => { 127 | log::debug!("logged out"); 128 | self.config.username = None; 129 | self.state = State::Login; 130 | } 131 | } 132 | } 133 | 134 | pub(crate) fn logging_in(&self) -> bool { 135 | if let State::Login = self.state { 136 | true 137 | } else { 138 | false 139 | } 140 | } 141 | 142 | pub(crate) fn choosing(&self) -> bool { 143 | if let State::List(..) = self.state { 144 | true 145 | } else { 146 | false 147 | } 148 | } 149 | 150 | pub(crate) fn watching(&self) -> bool { 151 | if let State::Watch(..) = self.state { 152 | true 153 | } else { 154 | false 155 | } 156 | } 157 | 158 | pub(crate) fn username(&self) -> Option<&str> { 159 | self.config.username.as_ref().map(|s| s.as_str()) 160 | } 161 | 162 | pub(crate) fn sessions(&self) -> &[crate::protocol::Session] { 163 | if let State::List(sessions) = &self.state { 164 | sessions 165 | } else { 166 | &[] 167 | } 168 | } 169 | 170 | pub(crate) fn screen(&self) -> Option<&vt100::Screen> { 171 | if let State::Watch(conn) = &self.state { 172 | Some(conn.term.screen()) 173 | } else { 174 | None 175 | } 176 | } 177 | 178 | pub(crate) fn received_data(&self) -> bool { 179 | if let State::Watch(conn) = &self.state { 180 | conn.received_data 181 | } else { 182 | false 183 | } 184 | } 185 | 186 | pub(crate) fn allowed_login_method( 187 | &self, 188 | ty: crate::protocol::AuthType, 189 | ) -> bool { 190 | self.config.allowed_login_methods.contains(&ty) 191 | } 192 | 193 | pub(crate) fn oauth_login_url( 194 | &self, 195 | ty: crate::protocol::AuthType, 196 | ) -> Option<&str> { 197 | self.config.oauth_login_urls.get(&ty).map(|s| s.as_str()) 198 | } 199 | 200 | fn login(&self, username: &str, orders: &mut impl Orders) { 201 | let url = format!( 202 | "http://{}/login?username={}", 203 | self.config.public_address, username 204 | ); 205 | orders.perform_cmd( 206 | seed::Request::new(url).fetch_json_data(crate::Msg::LoggedIn), 207 | ); 208 | } 209 | 210 | fn list(&self, orders: &mut impl Orders) { 211 | let url = format!("http://{}/list", self.config.public_address); 212 | orders.perform_cmd( 213 | seed::Request::new(url).fetch_json_data(crate::Msg::List), 214 | ); 215 | } 216 | 217 | fn watch(&mut self, id: &str, orders: &mut impl Orders) { 218 | let url = 219 | format!("ws://{}/watch?id={}", self.config.public_address, id); 220 | let ws = crate::ws::connect(&url, id, crate::Msg::Watch, orders); 221 | self.state = State::Watch(WatchConn::new(ws)); 222 | } 223 | 224 | fn logout(&self, orders: &mut impl Orders) { 225 | let url = format!("http://{}/logout", self.config.public_address); 226 | orders.perform_cmd( 227 | seed::Request::new(url).fetch(crate::Msg::LoggedOut), 228 | ); 229 | } 230 | 231 | fn process(&mut self, bytes: &[u8]) { 232 | if let State::Watch(conn) = &mut self.state { 233 | conn.term.process(bytes); 234 | conn.received_data = true; 235 | } 236 | } 237 | 238 | fn set_size(&mut self, rows: u16, cols: u16) { 239 | if let State::Watch(conn) = &mut self.state { 240 | conn.term.set_size(rows, cols); 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /teleterm/src/cmd/record.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use tokio::io::AsyncWrite as _; 3 | 4 | #[derive(serde::Deserialize, Debug, Default)] 5 | pub struct Config { 6 | #[serde(default)] 7 | command: crate::config::Command, 8 | 9 | #[serde(default)] 10 | ttyrec: crate::config::Ttyrec, 11 | } 12 | 13 | impl crate::config::Config for Config { 14 | fn merge_args<'a>( 15 | &mut self, 16 | matches: &clap::ArgMatches<'a>, 17 | ) -> Result<()> { 18 | self.command.merge_args(matches)?; 19 | self.ttyrec.merge_args(matches)?; 20 | Ok(()) 21 | } 22 | 23 | fn run( 24 | &self, 25 | ) -> Box + Send> { 26 | Box::new(RecordSession::new( 27 | &self.ttyrec.filename, 28 | &self.command.command, 29 | &self.command.args, 30 | )) 31 | } 32 | } 33 | 34 | pub fn cmd<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> { 35 | crate::config::Command::cmd(crate::config::Ttyrec::cmd( 36 | app.about("Record a terminal session to a file"), 37 | )) 38 | } 39 | 40 | pub fn config( 41 | config: Option, 42 | ) -> Result> { 43 | let config: Config = if let Some(config) = config { 44 | config 45 | .try_into() 46 | .context(crate::error::CouldntParseConfig)? 47 | } else { 48 | Config::default() 49 | }; 50 | Ok(Box::new(config)) 51 | } 52 | 53 | #[allow(clippy::large_enum_variant)] 54 | enum FileState { 55 | Closed { 56 | filename: String, 57 | }, 58 | Opening { 59 | filename: String, 60 | fut: tokio::fs::file::CreateFuture, 61 | }, 62 | Open { 63 | writer: ttyrec::Writer, 64 | }, 65 | } 66 | 67 | struct RecordSession { 68 | file: FileState, 69 | frame_data: Vec, 70 | 71 | process: 72 | tokio_pty_process_stream::ResizingProcess, 73 | raw_screen: Option, 74 | done: bool, 75 | 76 | stdout: tokio::io::Stdout, 77 | to_write_stdout: std::collections::VecDeque, 78 | needs_flush: bool, 79 | } 80 | 81 | impl RecordSession { 82 | fn new(filename: &str, cmd: &str, args: &[String]) -> Self { 83 | let input = crate::async_stdin::Stdin::new(); 84 | let process = tokio_pty_process_stream::ResizingProcess::new( 85 | tokio_pty_process_stream::Process::new(cmd, args, input), 86 | ); 87 | 88 | Self { 89 | file: FileState::Closed { 90 | filename: filename.to_string(), 91 | }, 92 | frame_data: vec![], 93 | 94 | process, 95 | raw_screen: None, 96 | done: false, 97 | 98 | stdout: tokio::io::stdout(), 99 | to_write_stdout: std::collections::VecDeque::new(), 100 | needs_flush: false, 101 | } 102 | } 103 | 104 | fn record_bytes(&mut self, buf: &[u8]) { 105 | self.frame_data.extend(buf); 106 | self.to_write_stdout.extend(buf); 107 | } 108 | } 109 | 110 | impl RecordSession { 111 | const POLL_FNS: 112 | &'static [&'static dyn for<'a> Fn( 113 | &'a mut Self, 114 | ) 115 | -> component_future::Poll< 116 | (), 117 | Error, 118 | >] = &[ 119 | &Self::poll_open_file, 120 | &Self::poll_read_process, 121 | &Self::poll_write_terminal, 122 | &Self::poll_flush_terminal, 123 | &Self::poll_write_file, 124 | ]; 125 | 126 | fn poll_open_file(&mut self) -> component_future::Poll<(), Error> { 127 | match &mut self.file { 128 | FileState::Closed { filename } => { 129 | self.file = FileState::Opening { 130 | filename: filename.to_string(), 131 | fut: tokio::fs::File::create(filename.to_string()), 132 | }; 133 | Ok(component_future::Async::DidWork) 134 | } 135 | FileState::Opening { filename, fut } => { 136 | let file = component_future::try_ready!(fut 137 | .poll() 138 | .with_context(|| { 139 | crate::error::OpenFile { 140 | filename: filename.clone(), 141 | } 142 | })); 143 | self.file = FileState::Open { 144 | writer: ttyrec::Writer::new(file), 145 | }; 146 | Ok(component_future::Async::DidWork) 147 | } 148 | FileState::Open { .. } => { 149 | Ok(component_future::Async::NothingToDo) 150 | } 151 | } 152 | } 153 | 154 | fn poll_read_process(&mut self) -> component_future::Poll<(), Error> { 155 | match component_future::try_ready!(self 156 | .process 157 | .poll() 158 | .context(crate::error::Subprocess)) 159 | { 160 | Some(tokio_pty_process_stream::Event::CommandStart { 161 | .. 162 | }) => { 163 | if self.raw_screen.is_none() { 164 | self.raw_screen = Some( 165 | crossterm::screen::RawScreen::into_raw_mode() 166 | .context(crate::error::ToRawMode)?, 167 | ); 168 | } 169 | } 170 | Some(tokio_pty_process_stream::Event::CommandExit { .. }) => { 171 | self.done = true; 172 | } 173 | Some(tokio_pty_process_stream::Event::Output { data }) => { 174 | self.record_bytes(&data); 175 | } 176 | Some(tokio_pty_process_stream::Event::Resize { .. }) => {} 177 | None => { 178 | if !self.done { 179 | unreachable!() 180 | } 181 | // don't return final event here - wait until we are done 182 | // writing all data to the file (see poll_write_file) 183 | } 184 | } 185 | Ok(component_future::Async::DidWork) 186 | } 187 | 188 | fn poll_write_terminal(&mut self) -> component_future::Poll<(), Error> { 189 | if self.to_write_stdout.is_empty() { 190 | return Ok(component_future::Async::NothingToDo); 191 | } 192 | 193 | let (a, b) = self.to_write_stdout.as_slices(); 194 | let buf = if a.is_empty() { b } else { a }; 195 | let n = component_future::try_ready!(self 196 | .stdout 197 | .poll_write(buf) 198 | .context(crate::error::WriteTerminal)); 199 | for _ in 0..n { 200 | self.to_write_stdout.pop_front(); 201 | } 202 | self.needs_flush = true; 203 | Ok(component_future::Async::DidWork) 204 | } 205 | 206 | fn poll_flush_terminal(&mut self) -> component_future::Poll<(), Error> { 207 | if !self.needs_flush { 208 | return Ok(component_future::Async::NothingToDo); 209 | } 210 | 211 | component_future::try_ready!(self 212 | .stdout 213 | .poll_flush() 214 | .context(crate::error::FlushTerminal)); 215 | self.needs_flush = false; 216 | Ok(component_future::Async::DidWork) 217 | } 218 | 219 | fn poll_write_file(&mut self) -> component_future::Poll<(), Error> { 220 | let writer = match &mut self.file { 221 | FileState::Open { writer } => writer, 222 | _ => { 223 | return Ok(component_future::Async::NothingToDo); 224 | } 225 | }; 226 | 227 | if !self.frame_data.is_empty() { 228 | writer 229 | .frame(&self.frame_data) 230 | .context(crate::error::WriteTtyrec)?; 231 | self.frame_data.clear(); 232 | } 233 | 234 | if writer.needs_write() { 235 | component_future::try_ready!(writer 236 | .poll_write() 237 | .context(crate::error::WriteTtyrec)); 238 | Ok(component_future::Async::DidWork) 239 | } else { 240 | // finish writing to the file before actually ending 241 | if self.done { 242 | Ok(component_future::Async::Ready(())) 243 | } else { 244 | Ok(component_future::Async::NothingToDo) 245 | } 246 | } 247 | } 248 | } 249 | 250 | #[must_use = "futures do nothing unless polled"] 251 | impl futures::Future for RecordSession { 252 | type Item = (); 253 | type Error = Error; 254 | 255 | fn poll(&mut self) -> futures::Poll { 256 | component_future::poll_future(self, Self::POLL_FNS) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /teleterm/src/web/watch.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | 3 | use gotham::state::FromState as _; 4 | use tokio_tungstenite::tungstenite; 5 | 6 | #[derive( 7 | serde::Deserialize, 8 | gotham_derive::StateData, 9 | gotham_derive::StaticResponseExtender, 10 | )] 11 | pub struct QueryParams { 12 | id: String, 13 | } 14 | 15 | pub fn run( 16 | mut state: gotham::state::State, 17 | ) -> (gotham::state::State, hyper::Response) { 18 | let session = gotham::middleware::session::SessionData::< 19 | crate::web::SessionData, 20 | >::borrow_from(&state); 21 | let auth = if let Some(login) = &session.login { 22 | login.auth.clone() 23 | } else { 24 | return ( 25 | state, 26 | hyper::Response::builder() 27 | .status(hyper::StatusCode::FORBIDDEN) 28 | .body(hyper::Body::empty()) 29 | .unwrap(), 30 | ); 31 | }; 32 | 33 | let body = hyper::Body::take_from(&mut state); 34 | let headers = hyper::HeaderMap::take_from(&mut state); 35 | let config = crate::web::Config::borrow_from(&state); 36 | 37 | if crate::web::ws::requested(&headers) { 38 | let (response, stream) = match crate::web::ws::accept(&headers, body) 39 | { 40 | Ok(res) => res, 41 | Err(_) => { 42 | log::error!("failed to accept websocket request"); 43 | return ( 44 | state, 45 | hyper::Response::builder() 46 | .status(hyper::StatusCode::BAD_REQUEST) 47 | .body(hyper::Body::empty()) 48 | .unwrap(), 49 | ); 50 | } 51 | }; 52 | 53 | let query_params = QueryParams::borrow_from(&state); 54 | 55 | let (_, address) = config.server_address; 56 | let connector: crate::client::Connector<_> = Box::new(move || { 57 | Box::new( 58 | tokio::net::tcp::TcpStream::connect(&address) 59 | .context(crate::error::Connect { address }), 60 | ) 61 | }); 62 | let client = crate::client::Client::raw( 63 | "teleterm-web", 64 | connector, 65 | &auth, 66 | crate::protocol::AuthClient::Web, 67 | ); 68 | 69 | tokio::spawn( 70 | Connection::new( 71 | gotham::state::request_id(&state), 72 | client, 73 | &query_params.id, 74 | ConnectionState::Connecting(Box::new( 75 | stream.context(crate::error::WebSocketAccept), 76 | )), 77 | ) 78 | .map_err(|e| log::error!("{}", e)), 79 | ); 80 | 81 | (state, response) 82 | } else { 83 | ( 84 | state, 85 | hyper::Response::new(hyper::Body::from( 86 | "non-websocket request to websocket endpoint", 87 | )), 88 | ) 89 | } 90 | } 91 | 92 | type WebSocketConnectionFuture = Box< 93 | dyn futures::Future< 94 | Item = tokio_tungstenite::WebSocketStream< 95 | hyper::upgrade::Upgraded, 96 | >, 97 | Error = Error, 98 | > + Send, 99 | >; 100 | type MessageSink = Box< 101 | dyn futures::Sink 102 | + Send, 103 | >; 104 | type MessageStream = Box< 105 | dyn futures::Stream + Send, 106 | >; 107 | 108 | enum SenderState { 109 | Temporary, 110 | Connected(MessageSink), 111 | Sending( 112 | Box + Send>, 113 | ), 114 | Flushing( 115 | Box + Send>, 116 | ), 117 | } 118 | 119 | enum ConnectionState { 120 | Connecting(WebSocketConnectionFuture), 121 | Connected(SenderState, MessageStream), 122 | } 123 | 124 | impl ConnectionState { 125 | fn sink(&mut self) -> Option<&mut MessageSink> { 126 | match self { 127 | Self::Connected(sender, _) => match sender { 128 | SenderState::Connected(sink) => Some(sink), 129 | _ => None, 130 | }, 131 | _ => None, 132 | } 133 | } 134 | 135 | fn send(&mut self, msg: tungstenite::Message) { 136 | match self { 137 | Self::Connected(sender, _) => { 138 | let fut = 139 | match std::mem::replace(sender, SenderState::Temporary) { 140 | SenderState::Connected(sink) => sink.send(msg), 141 | _ => unreachable!(), 142 | }; 143 | *sender = SenderState::Sending(Box::new(fut)); 144 | } 145 | _ => unreachable!(), 146 | } 147 | } 148 | } 149 | 150 | struct Connection< 151 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 152 | > { 153 | id: String, 154 | client: crate::client::Client, 155 | watch_id: String, 156 | conn: ConnectionState, 157 | } 158 | 159 | impl 160 | Connection 161 | { 162 | fn new( 163 | id: &str, 164 | client: crate::client::Client, 165 | watch_id: &str, 166 | conn: ConnectionState, 167 | ) -> Self { 168 | Self { 169 | client, 170 | id: id.to_string(), 171 | watch_id: watch_id.to_string(), 172 | conn, 173 | } 174 | } 175 | 176 | fn handle_client_message( 177 | &mut self, 178 | msg: &crate::protocol::Message, 179 | ) -> Result> { 180 | match msg { 181 | crate::protocol::Message::TerminalOutput { .. } 182 | | crate::protocol::Message::Disconnected 183 | | crate::protocol::Message::Resize { .. } => { 184 | let json = serde_json::to_string(msg) 185 | .context(crate::error::SerializeMessage)?; 186 | Ok(Some(tungstenite::Message::Text(json))) 187 | } 188 | crate::protocol::Message::LoggedIn { .. } => { 189 | self.client.send_message( 190 | crate::protocol::Message::start_watching(&self.watch_id), 191 | ); 192 | Ok(None) 193 | } 194 | _ => Ok(None), 195 | } 196 | } 197 | 198 | fn handle_websocket_message( 199 | &mut self, 200 | msg: &tungstenite::Message, 201 | ) -> Result<()> { 202 | // TODO 203 | log::info!("websocket stream message for {}: {:?}", self.id, msg); 204 | Ok(()) 205 | } 206 | } 207 | 208 | impl 209 | Connection 210 | { 211 | const POLL_FNS: 212 | &'static [&'static dyn for<'a> Fn( 213 | &'a mut Self, 214 | ) 215 | -> component_future::Poll< 216 | (), 217 | Error, 218 | >] = &[&Self::poll_client, &Self::poll_websocket_stream]; 219 | 220 | fn poll_client(&mut self) -> component_future::Poll<(), Error> { 221 | // don't start up the client until the websocket connection is fully 222 | // established and isn't busy 223 | if self.conn.sink().is_none() { 224 | return Ok(component_future::Async::NothingToDo); 225 | }; 226 | 227 | match component_future::try_ready!(self.client.poll()).unwrap() { 228 | crate::client::Event::ServerMessage(msg) => { 229 | if let Some(msg) = self.handle_client_message(&msg)? { 230 | self.conn.send(msg); 231 | } 232 | } 233 | _ => unreachable!(), 234 | } 235 | Ok(component_future::Async::DidWork) 236 | } 237 | 238 | fn poll_websocket_stream(&mut self) -> component_future::Poll<(), Error> { 239 | match &mut self.conn { 240 | ConnectionState::Connecting(fut) => { 241 | let stream = component_future::try_ready!(fut.poll()); 242 | let (sink, stream) = stream.split(); 243 | self.conn = ConnectionState::Connected( 244 | SenderState::Connected(Box::new( 245 | sink.sink_map_err(|e| Error::WebSocket { source: e }), 246 | )), 247 | Box::new(stream.context(crate::error::WebSocket)), 248 | ); 249 | Ok(component_future::Async::DidWork) 250 | } 251 | ConnectionState::Connected(sender, stream) => match sender { 252 | SenderState::Temporary => unreachable!(), 253 | SenderState::Connected(_) => { 254 | if let Some(msg) = 255 | component_future::try_ready!(stream.poll()) 256 | { 257 | self.handle_websocket_message(&msg)?; 258 | Ok(component_future::Async::DidWork) 259 | } else { 260 | log::info!("disconnect for {}", self.id); 261 | Ok(component_future::Async::Ready(())) 262 | } 263 | } 264 | SenderState::Sending(fut) => { 265 | let sink = component_future::try_ready!(fut.poll()); 266 | *sender = SenderState::Flushing(Box::new(sink.flush())); 267 | Ok(component_future::Async::DidWork) 268 | } 269 | SenderState::Flushing(fut) => { 270 | let sink = component_future::try_ready!(fut.poll()); 271 | *sender = SenderState::Connected(Box::new(sink)); 272 | Ok(component_future::Async::DidWork) 273 | } 274 | }, 275 | } 276 | } 277 | } 278 | 279 | impl 280 | futures::Future for Connection 281 | { 282 | type Item = (); 283 | type Error = Error; 284 | 285 | fn poll(&mut self) -> futures::Poll { 286 | component_future::poll_future(self, Self::POLL_FNS) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /teleterm-web/src/views/terminal.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use unicode_width::UnicodeWidthStr as _; 3 | 4 | pub(crate) fn render(screen: &vt100::Screen) -> Node { 5 | let (rows, cols) = screen.size(); 6 | let (cursor_row, cursor_col) = screen.cursor_position(); 7 | 8 | let mut grid = vec![]; 9 | for row_idx in 0..rows { 10 | let mut row = vec![]; 11 | for col_idx in 0..cols { 12 | let cell = screen.cell(row_idx, col_idx).unwrap(); 13 | 14 | let mut contents = cell.contents(); 15 | // if we don't use a non-breaking space for cells with no 16 | // foreground contents, the table layout may just collapse those 17 | // cells. we can't just set a fixed height because there's no way 18 | // (that i'm aware of) in css to set a box to have a fixed height 19 | // that is the same as the line height of the current font. 20 | if contents.trim().is_empty() || contents.width() == 0 { 21 | contents = "\u{00a0}".to_string(); 22 | } 23 | 24 | row.push(seed::td![ 25 | seed::attrs! { At::Class => "cell" }, 26 | style_for_cell( 27 | cell, 28 | cursor_row == row_idx 29 | && cursor_col == col_idx 30 | && !screen.hide_cursor() 31 | ), 32 | contents 33 | ]) 34 | } 35 | grid.push(seed::tr![seed::attrs! { At::Class => "row" }, row]); 36 | } 37 | 38 | seed::table![seed::attrs! { At::Class => "grid" }, grid] 39 | } 40 | 41 | fn style_for_cell( 42 | cell: &vt100::Cell, 43 | is_cursor: bool, 44 | ) -> seed::dom_types::Style { 45 | let mut fgcolor = cell.fgcolor(); 46 | let mut bgcolor = cell.bgcolor(); 47 | if is_cursor { 48 | fgcolor = vt100::Color::Rgb(0, 0, 0); 49 | bgcolor = vt100::Color::Rgb(0, 0xff, 0); 50 | } else if cell.inverse() { 51 | if fgcolor == bgcolor { 52 | fgcolor = vt100::Color::Rgb(0, 0, 0); 53 | bgcolor = vt100::Color::Rgb(0xd3, 0xd3, 0xd3); 54 | } else { 55 | std::mem::swap(&mut fgcolor, &mut bgcolor); 56 | } 57 | } 58 | seed::style! { 59 | St::Color => color(fgcolor, cell.bold()), 60 | St::BackgroundColor => color(bgcolor, false), 61 | St::FontStyle => if cell.italic() { "italic" } else { "normal" }, 62 | St::TextDecoration => if cell.underline() { 63 | "underline" 64 | } else { 65 | "none" 66 | }, 67 | } 68 | } 69 | 70 | fn color(color: vt100::Color, bright: bool) -> Option { 71 | match color { 72 | vt100::Color::Default => None, 73 | vt100::Color::Idx(n) => Some(indexed_color(n, bright).to_string()), 74 | vt100::Color::Rgb(r, g, b) => { 75 | Some(format!("#{:02x}{:02x}{:02x}", r, g, b)) 76 | } 77 | } 78 | } 79 | 80 | fn indexed_color(mut idx: u8, bright: bool) -> &'static str { 81 | if idx < 8 && bright { 82 | idx += 8; 83 | } 84 | 85 | match idx { 86 | 0 => "#000000", 87 | 1 => "#800000", 88 | 2 => "#008000", 89 | 3 => "#808000", 90 | 4 => "#000080", 91 | 5 => "#800080", 92 | 6 => "#008080", 93 | 7 => "#c0c0c0", 94 | 8 => "#808080", 95 | 9 => "#ff0000", 96 | 10 => "#00ff00", 97 | 11 => "#ffff00", 98 | 12 => "#0000ff", 99 | 13 => "#ff00ff", 100 | 14 => "#00ffff", 101 | 15 => "#ffffff", 102 | 16 => "#000000", 103 | 17 => "#00005f", 104 | 18 => "#000087", 105 | 19 => "#0000af", 106 | 20 => "#0000d7", 107 | 21 => "#0000ff", 108 | 22 => "#005f00", 109 | 23 => "#005f5f", 110 | 24 => "#005f87", 111 | 25 => "#005faf", 112 | 26 => "#005fd7", 113 | 27 => "#005fff", 114 | 28 => "#008700", 115 | 29 => "#00875f", 116 | 30 => "#008787", 117 | 31 => "#0087af", 118 | 32 => "#0087d7", 119 | 33 => "#0087ff", 120 | 34 => "#00af00", 121 | 35 => "#00af5f", 122 | 36 => "#00af87", 123 | 37 => "#00afaf", 124 | 38 => "#00afd7", 125 | 39 => "#00afff", 126 | 40 => "#00d700", 127 | 41 => "#00d75f", 128 | 42 => "#00d787", 129 | 43 => "#00d7af", 130 | 44 => "#00d7d7", 131 | 45 => "#00d7ff", 132 | 46 => "#00ff00", 133 | 47 => "#00ff5f", 134 | 48 => "#00ff87", 135 | 49 => "#00ffaf", 136 | 50 => "#00ffd7", 137 | 51 => "#00ffff", 138 | 52 => "#5f0000", 139 | 53 => "#5f005f", 140 | 54 => "#5f0087", 141 | 55 => "#5f00af", 142 | 56 => "#5f00d7", 143 | 57 => "#5f00ff", 144 | 58 => "#5f5f00", 145 | 59 => "#5f5f5f", 146 | 60 => "#5f5f87", 147 | 61 => "#5f5faf", 148 | 62 => "#5f5fd7", 149 | 63 => "#5f5fff", 150 | 64 => "#5f8700", 151 | 65 => "#5f875f", 152 | 66 => "#5f8787", 153 | 67 => "#5f87af", 154 | 68 => "#5f87d7", 155 | 69 => "#5f87ff", 156 | 70 => "#5faf00", 157 | 71 => "#5faf5f", 158 | 72 => "#5faf87", 159 | 73 => "#5fafaf", 160 | 74 => "#5fafd7", 161 | 75 => "#5fafff", 162 | 76 => "#5fd700", 163 | 77 => "#5fd75f", 164 | 78 => "#5fd787", 165 | 79 => "#5fd7af", 166 | 80 => "#5fd7d7", 167 | 81 => "#5fd7ff", 168 | 82 => "#5fff00", 169 | 83 => "#5fff5f", 170 | 84 => "#5fff87", 171 | 85 => "#5fffaf", 172 | 86 => "#5fffd7", 173 | 87 => "#5fffff", 174 | 88 => "#870000", 175 | 89 => "#87005f", 176 | 90 => "#870087", 177 | 91 => "#8700af", 178 | 92 => "#8700d7", 179 | 93 => "#8700ff", 180 | 94 => "#875f00", 181 | 95 => "#875f5f", 182 | 96 => "#875f87", 183 | 97 => "#875faf", 184 | 98 => "#875fd7", 185 | 99 => "#875fff", 186 | 100 => "#878700", 187 | 101 => "#87875f", 188 | 102 => "#878787", 189 | 103 => "#8787af", 190 | 104 => "#8787d7", 191 | 105 => "#8787ff", 192 | 106 => "#87af00", 193 | 107 => "#87af5f", 194 | 108 => "#87af87", 195 | 109 => "#87afaf", 196 | 110 => "#87afd7", 197 | 111 => "#87afff", 198 | 112 => "#87d700", 199 | 113 => "#87d75f", 200 | 114 => "#87d787", 201 | 115 => "#87d7af", 202 | 116 => "#87d7d7", 203 | 117 => "#87d7ff", 204 | 118 => "#87ff00", 205 | 119 => "#87ff5f", 206 | 120 => "#87ff87", 207 | 121 => "#87ffaf", 208 | 122 => "#87ffd7", 209 | 123 => "#87ffff", 210 | 124 => "#af0000", 211 | 125 => "#af005f", 212 | 126 => "#af0087", 213 | 127 => "#af00af", 214 | 128 => "#af00d7", 215 | 129 => "#af00ff", 216 | 130 => "#af5f00", 217 | 131 => "#af5f5f", 218 | 132 => "#af5f87", 219 | 133 => "#af5faf", 220 | 134 => "#af5fd7", 221 | 135 => "#af5fff", 222 | 136 => "#af8700", 223 | 137 => "#af875f", 224 | 138 => "#af8787", 225 | 139 => "#af87af", 226 | 140 => "#af87d7", 227 | 141 => "#af87ff", 228 | 142 => "#afaf00", 229 | 143 => "#afaf5f", 230 | 144 => "#afaf87", 231 | 145 => "#afafaf", 232 | 146 => "#afafd7", 233 | 147 => "#afafff", 234 | 148 => "#afd700", 235 | 149 => "#afd75f", 236 | 150 => "#afd787", 237 | 151 => "#afd7af", 238 | 152 => "#afd7d7", 239 | 153 => "#afd7ff", 240 | 154 => "#afff00", 241 | 155 => "#afff5f", 242 | 156 => "#afff87", 243 | 157 => "#afffaf", 244 | 158 => "#afffd7", 245 | 159 => "#afffff", 246 | 160 => "#d70000", 247 | 161 => "#d7005f", 248 | 162 => "#d70087", 249 | 163 => "#d700af", 250 | 164 => "#d700d7", 251 | 165 => "#d700ff", 252 | 166 => "#d75f00", 253 | 167 => "#d75f5f", 254 | 168 => "#d75f87", 255 | 169 => "#d75faf", 256 | 170 => "#d75fd7", 257 | 171 => "#d75fff", 258 | 172 => "#d78700", 259 | 173 => "#d7875f", 260 | 174 => "#d78787", 261 | 175 => "#d787af", 262 | 176 => "#d787d7", 263 | 177 => "#d787ff", 264 | 178 => "#d7af00", 265 | 179 => "#d7af5f", 266 | 180 => "#d7af87", 267 | 181 => "#d7afaf", 268 | 182 => "#d7afd7", 269 | 183 => "#d7afff", 270 | 184 => "#d7d700", 271 | 185 => "#d7d75f", 272 | 186 => "#d7d787", 273 | 187 => "#d7d7af", 274 | 188 => "#d7d7d7", 275 | 189 => "#d7d7ff", 276 | 190 => "#d7ff00", 277 | 191 => "#d7ff5f", 278 | 192 => "#d7ff87", 279 | 193 => "#d7ffaf", 280 | 194 => "#d7ffd7", 281 | 195 => "#d7ffff", 282 | 196 => "#ff0000", 283 | 197 => "#ff005f", 284 | 198 => "#ff0087", 285 | 199 => "#ff00af", 286 | 200 => "#ff00d7", 287 | 201 => "#ff00ff", 288 | 202 => "#ff5f00", 289 | 203 => "#ff5f5f", 290 | 204 => "#ff5f87", 291 | 205 => "#ff5faf", 292 | 206 => "#ff5fd7", 293 | 207 => "#ff5fff", 294 | 208 => "#ff8700", 295 | 209 => "#ff875f", 296 | 210 => "#ff8787", 297 | 211 => "#ff87af", 298 | 212 => "#ff87d7", 299 | 213 => "#ff87ff", 300 | 214 => "#ffaf00", 301 | 215 => "#ffaf5f", 302 | 216 => "#ffaf87", 303 | 217 => "#ffafaf", 304 | 218 => "#ffafd7", 305 | 219 => "#ffafff", 306 | 220 => "#ffd700", 307 | 221 => "#ffd75f", 308 | 222 => "#ffd787", 309 | 223 => "#ffd7af", 310 | 224 => "#ffd7d7", 311 | 225 => "#ffd7ff", 312 | 226 => "#ffff00", 313 | 227 => "#ffff5f", 314 | 228 => "#ffff87", 315 | 229 => "#ffffaf", 316 | 230 => "#ffffd7", 317 | 231 => "#ffffff", 318 | 232 => "#080808", 319 | 233 => "#121212", 320 | 234 => "#1c1c1c", 321 | 235 => "#262626", 322 | 236 => "#303030", 323 | 237 => "#3a3a3a", 324 | 238 => "#444444", 325 | 239 => "#4e4e4e", 326 | 240 => "#585858", 327 | 241 => "#626262", 328 | 242 => "#6c6c6c", 329 | 243 => "#767676", 330 | 244 => "#808080", 331 | 245 => "#8a8a8a", 332 | 246 => "#949494", 333 | 247 => "#9e9e9e", 334 | 248 => "#a8a8a8", 335 | 249 => "#b2b2b2", 336 | 250 => "#bcbcbc", 337 | 251 => "#c6c6c6", 338 | 252 => "#d0d0d0", 339 | 253 => "#dadada", 340 | 254 => "#e4e4e4", 341 | 255 => "#eeeeee", 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /teleterm/src/session_list.rs: -------------------------------------------------------------------------------- 1 | pub struct SessionList { 2 | sessions: Vec, 3 | offset: usize, 4 | size: crate::term::Size, 5 | } 6 | 7 | impl SessionList { 8 | pub fn new( 9 | sessions: Vec, 10 | size: crate::term::Size, 11 | ) -> Self { 12 | let mut by_name = std::collections::HashMap::new(); 13 | for session in sessions { 14 | if !by_name.contains_key(&session.username) { 15 | by_name.insert(session.username.clone(), vec![]); 16 | } 17 | by_name.get_mut(&session.username).unwrap().push(session); 18 | } 19 | let mut names: Vec<_> = by_name.keys().cloned().collect(); 20 | names.sort_by(|a: &String, b: &String| { 21 | let a_idle = 22 | by_name[a].iter().min_by_key(|session| session.idle_time); 23 | let b_idle = 24 | by_name[b].iter().min_by_key(|session| session.idle_time); 25 | // these unwraps are safe because we know that none of the vecs in 26 | // the map can be empty 27 | a_idle.unwrap().idle_time.cmp(&b_idle.unwrap().idle_time) 28 | }); 29 | for name in &names { 30 | if let Some(sessions) = by_name.get_mut(name) { 31 | sessions.sort_by_key(|s| s.idle_time); 32 | } 33 | } 34 | 35 | let mut sorted = vec![]; 36 | for name in names { 37 | let sessions = by_name.remove(&name).unwrap(); 38 | for session in sessions { 39 | sorted.push(session); 40 | } 41 | } 42 | 43 | Self { 44 | sessions: sorted, 45 | offset: 0, 46 | size, 47 | } 48 | } 49 | 50 | pub fn visible_sessions(&self) -> &[crate::protocol::Session] { 51 | let start = self.offset; 52 | let end = self.offset + self.limit(); 53 | let end = end.min(self.sessions.len()); 54 | &self.sessions[start..end] 55 | } 56 | 57 | pub fn visible_sessions_with_chars( 58 | &self, 59 | ) -> impl Iterator { 60 | self.visible_sessions() 61 | .iter() 62 | .enumerate() 63 | .map(move |(i, s)| (self.idx_to_char(i).unwrap(), s)) 64 | } 65 | 66 | pub fn size(&self) -> crate::term::Size { 67 | self.size 68 | } 69 | 70 | pub fn resize(&mut self, size: crate::term::Size) { 71 | self.size = size; 72 | } 73 | 74 | pub fn id_for(&self, c: char) -> Option<&str> { 75 | self.char_to_idx(c).and_then(|i| { 76 | self.sessions.get(i + self.offset).map(|s| s.id.as_ref()) 77 | }) 78 | } 79 | 80 | pub fn next_page(&mut self) { 81 | let inc = self.limit(); 82 | if self.offset + inc < self.sessions.len() { 83 | self.offset += inc; 84 | } 85 | } 86 | 87 | pub fn prev_page(&mut self) { 88 | let dec = self.limit(); 89 | if self.offset >= dec { 90 | self.offset -= dec; 91 | } 92 | } 93 | 94 | pub fn current_page(&self) -> usize { 95 | self.offset / self.limit() + 1 96 | } 97 | 98 | pub fn total_pages(&self) -> usize { 99 | if self.sessions.is_empty() { 100 | 1 101 | } else { 102 | (self.sessions.len() - 1) / self.limit() + 1 103 | } 104 | } 105 | 106 | fn idx_to_char(&self, mut i: usize) -> Option { 107 | if i >= self.limit() { 108 | return None; 109 | } 110 | 111 | // 'q' shouldn't be a list option, since it is bound to quit 112 | if i >= 16 { 113 | i += 1; 114 | } 115 | 116 | #[allow(clippy::cast_possible_truncation)] 117 | Some(std::char::from_u32(('a' as u32) + (i as u32)).unwrap()) 118 | } 119 | 120 | fn char_to_idx(&self, c: char) -> Option { 121 | if c == 'q' { 122 | return None; 123 | } 124 | 125 | let i = ((c as i32) - ('a' as i32)) as isize; 126 | if i < 0 { 127 | return None; 128 | } 129 | #[allow(clippy::cast_sign_loss)] 130 | let mut i = i as usize; 131 | 132 | // 'q' shouldn't be a list option, since it is bound to quit 133 | if i > 16 { 134 | i -= 1; 135 | } 136 | 137 | if i >= self.limit() { 138 | return None; 139 | } 140 | 141 | Some(i) 142 | } 143 | 144 | fn limit(&self) -> usize { 145 | let limit = self.size.rows as usize - 6; 146 | 147 | // enough for a-z except q - if we want to allow more than this, we'll 148 | // need to come up with a better way of choosing streams 149 | if limit > 25 { 150 | 25 151 | } else { 152 | limit 153 | } 154 | } 155 | } 156 | 157 | #[cfg(test)] 158 | #[allow(clippy::cognitive_complexity)] 159 | #[allow(clippy::redundant_clone)] 160 | #[allow(clippy::shadow_unrelated)] 161 | mod test { 162 | use super::*; 163 | 164 | fn session(username: &str, idle: u32) -> crate::protocol::Session { 165 | crate::protocol::Session { 166 | id: format!("{}", uuid::Uuid::new_v4()), 167 | username: username.to_string(), 168 | term_type: "screen".to_string(), 169 | size: crate::term::Size { rows: 24, cols: 80 }, 170 | idle_time: idle, 171 | title: "title".to_string(), 172 | watchers: 0, 173 | } 174 | } 175 | 176 | #[test] 177 | fn test_session_list_sorting() { 178 | let size = crate::term::Size { rows: 24, cols: 80 }; 179 | 180 | let session1 = session("doy", 35); 181 | let session2 = session("doy", 3); 182 | let mut session3 = session("sartak", 12); 183 | let session4 = session("sartak", 100); 184 | let mut session5 = session("toft", 5); 185 | let mut sessions = vec![ 186 | session1.clone(), 187 | session2.clone(), 188 | session3.clone(), 189 | session4.clone(), 190 | session5.clone(), 191 | ]; 192 | 193 | assert_eq!( 194 | SessionList::new(sessions.clone(), size.clone()).sessions, 195 | vec![ 196 | session2.clone(), 197 | session1.clone(), 198 | session5.clone(), 199 | session3.clone(), 200 | session4.clone(), 201 | ] 202 | ); 203 | 204 | session3.idle_time = 2; 205 | sessions[2].idle_time = 2; 206 | assert_eq!( 207 | SessionList::new(sessions.clone(), size.clone()).sessions, 208 | vec![ 209 | session3.clone(), 210 | session4.clone(), 211 | session2.clone(), 212 | session1.clone(), 213 | session5.clone(), 214 | ] 215 | ); 216 | 217 | session5.idle_time = 1; 218 | sessions[4].idle_time = 1; 219 | assert_eq!( 220 | SessionList::new(sessions.clone(), size.clone()).sessions, 221 | vec![ 222 | session5.clone(), 223 | session3.clone(), 224 | session4.clone(), 225 | session2.clone(), 226 | session1.clone(), 227 | ] 228 | ); 229 | } 230 | 231 | #[test] 232 | fn test_session_list_pagination() { 233 | let size = crate::term::Size { rows: 11, cols: 80 }; 234 | let sessions = vec![ 235 | session("doy", 0), 236 | session("doy", 1), 237 | session("doy", 2), 238 | session("doy", 3), 239 | session("doy", 4), 240 | session("doy", 5), 241 | session("doy", 6), 242 | session("doy", 7), 243 | session("doy", 8), 244 | session("doy", 9), 245 | session("doy", 10), 246 | ]; 247 | let mut list = SessionList::new(sessions.clone(), size); 248 | assert_eq!(list.limit(), 5); 249 | assert_eq!(list.total_pages(), 3); 250 | assert_eq!(list.current_page(), 1); 251 | 252 | list.next_page(); 253 | assert_eq!(list.limit(), 5); 254 | assert_eq!(list.total_pages(), 3); 255 | assert_eq!(list.current_page(), 2); 256 | 257 | list.next_page(); 258 | assert_eq!(list.limit(), 5); 259 | assert_eq!(list.total_pages(), 3); 260 | assert_eq!(list.current_page(), 3); 261 | 262 | list.next_page(); 263 | assert_eq!(list.limit(), 5); 264 | assert_eq!(list.total_pages(), 3); 265 | assert_eq!(list.current_page(), 3); 266 | 267 | list.prev_page(); 268 | assert_eq!(list.limit(), 5); 269 | assert_eq!(list.total_pages(), 3); 270 | assert_eq!(list.current_page(), 2); 271 | 272 | list.prev_page(); 273 | assert_eq!(list.limit(), 5); 274 | assert_eq!(list.total_pages(), 3); 275 | assert_eq!(list.current_page(), 1); 276 | 277 | list.prev_page(); 278 | assert_eq!(list.limit(), 5); 279 | assert_eq!(list.total_pages(), 3); 280 | assert_eq!(list.current_page(), 1); 281 | 282 | let id = list.id_for('a').unwrap(); 283 | assert_eq!(id, sessions[0].id); 284 | let id = list.id_for('e').unwrap(); 285 | assert_eq!(id, sessions[4].id); 286 | let id = list.id_for('f'); 287 | assert!(id.is_none()); 288 | 289 | list.next_page(); 290 | let id = list.id_for('a').unwrap(); 291 | assert_eq!(id, sessions[5].id); 292 | 293 | list.next_page(); 294 | let id = list.id_for('a').unwrap(); 295 | assert_eq!(id, sessions[10].id); 296 | let id = list.id_for('b'); 297 | assert!(id.is_none()); 298 | 299 | let size = crate::term::Size { rows: 24, cols: 80 }; 300 | let sessions = vec![ 301 | session("doy", 0), 302 | session("doy", 1), 303 | session("doy", 2), 304 | session("doy", 3), 305 | session("doy", 4), 306 | session("doy", 5), 307 | session("doy", 6), 308 | session("doy", 7), 309 | session("doy", 8), 310 | session("doy", 9), 311 | session("doy", 10), 312 | session("doy", 11), 313 | session("doy", 12), 314 | session("doy", 13), 315 | session("doy", 14), 316 | session("doy", 15), 317 | session("doy", 16), 318 | session("doy", 17), 319 | session("doy", 18), 320 | session("doy", 19), 321 | session("doy", 20), 322 | session("doy", 21), 323 | ]; 324 | let list = SessionList::new(sessions.clone(), size); 325 | assert_eq!(list.limit(), 18); 326 | assert_eq!(list.total_pages(), 2); 327 | assert_eq!(list.current_page(), 1); 328 | 329 | let id = list.id_for('a').unwrap(); 330 | assert_eq!(id, sessions[0].id); 331 | let id = list.id_for('p').unwrap(); 332 | assert_eq!(id, sessions[15].id); 333 | let id = list.id_for('q'); 334 | assert!(id.is_none()); 335 | let id = list.id_for('r').unwrap(); 336 | assert_eq!(id, sessions[16].id); 337 | let id = list.id_for('s').unwrap(); 338 | assert_eq!(id, sessions[17].id); 339 | let id = list.id_for('t'); 340 | assert!(id.is_none()); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /teleterm/src/cmd/stream.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use tokio::io::AsyncWrite as _; 3 | 4 | #[derive(serde::Deserialize, Debug, Default)] 5 | pub struct Config { 6 | #[serde(default)] 7 | client: crate::config::Client, 8 | 9 | #[serde(default)] 10 | command: crate::config::Command, 11 | } 12 | 13 | impl crate::config::Config for Config { 14 | fn merge_args<'a>( 15 | &mut self, 16 | matches: &clap::ArgMatches<'a>, 17 | ) -> Result<()> { 18 | self.client.merge_args(matches)?; 19 | self.command.merge_args(matches)?; 20 | Ok(()) 21 | } 22 | 23 | fn run( 24 | &self, 25 | ) -> Box + Send> { 26 | let auth = match self.client.auth { 27 | crate::protocol::AuthType::Plain => { 28 | let username = self 29 | .client 30 | .username 31 | .clone() 32 | .context(crate::error::CouldntFindUsername); 33 | match username { 34 | Ok(username) => crate::protocol::Auth::plain(&username), 35 | Err(e) => return Box::new(futures::future::err(e)), 36 | } 37 | } 38 | crate::protocol::AuthType::RecurseCenter => { 39 | let id = crate::client::load_client_auth_id(self.client.auth); 40 | crate::protocol::Auth::recurse_center( 41 | id.as_ref().map(std::string::String::as_str), 42 | ) 43 | } 44 | }; 45 | 46 | let host = self.client.host().to_string(); 47 | let address = *self.client.addr(); 48 | if self.client.tls { 49 | let connector = match native_tls::TlsConnector::new() 50 | .context(crate::error::CreateConnector) 51 | { 52 | Ok(connector) => connector, 53 | Err(e) => return Box::new(futures::future::err(e)), 54 | }; 55 | let connect: crate::client::Connector<_> = Box::new(move || { 56 | let host = host.clone(); 57 | let connector = connector.clone(); 58 | let connector = tokio_tls::TlsConnector::from(connector); 59 | let stream = tokio::net::tcp::TcpStream::connect(&address); 60 | Box::new( 61 | stream 62 | .context(crate::error::Connect { address }) 63 | .and_then(move |stream| { 64 | connector 65 | .connect(&host, stream) 66 | .context(crate::error::ConnectTls { host }) 67 | }), 68 | ) 69 | }); 70 | Box::new(StreamSession::new( 71 | &self.command.command, 72 | &self.command.args, 73 | connect, 74 | &auth, 75 | )) 76 | } else { 77 | let connect: crate::client::Connector<_> = Box::new(move || { 78 | Box::new( 79 | tokio::net::tcp::TcpStream::connect(&address) 80 | .context(crate::error::Connect { address }), 81 | ) 82 | }); 83 | Box::new(StreamSession::new( 84 | &self.command.command, 85 | &self.command.args, 86 | connect, 87 | &auth, 88 | )) 89 | } 90 | } 91 | } 92 | 93 | pub fn cmd<'a, 'b>(app: clap::App<'a, 'b>) -> clap::App<'a, 'b> { 94 | crate::config::Client::cmd(crate::config::Command::cmd( 95 | app.about("Stream your terminal"), 96 | )) 97 | } 98 | 99 | pub fn config( 100 | mut config: Option, 101 | ) -> Result> { 102 | if config.is_none() { 103 | config = crate::config::wizard::run()?; 104 | } 105 | let config: Config = if let Some(config) = config { 106 | config 107 | .try_into() 108 | .context(crate::error::CouldntParseConfig)? 109 | } else { 110 | Config::default() 111 | }; 112 | Ok(Box::new(config)) 113 | } 114 | 115 | struct StreamSession< 116 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 117 | > { 118 | client: crate::client::Client, 119 | connected: bool, 120 | 121 | process: 122 | tokio_pty_process_stream::ResizingProcess, 123 | raw_screen: Option, 124 | done: bool, 125 | 126 | term: vt100::Parser, 127 | last_screen: vt100::Screen, 128 | needs_screen_update: bool, 129 | 130 | stdout: tokio::io::Stdout, 131 | to_print: std::collections::VecDeque, 132 | needs_flush: bool, 133 | } 134 | 135 | impl 136 | StreamSession 137 | { 138 | fn new( 139 | cmd: &str, 140 | args: &[String], 141 | connect: crate::client::Connector, 142 | auth: &crate::protocol::Auth, 143 | ) -> Self { 144 | let term_type = 145 | std::env::var("TERM").unwrap_or_else(|_| "".to_string()); 146 | let client = crate::client::Client::stream( 147 | &term_type, 148 | connect, 149 | auth, 150 | crate::protocol::AuthClient::Cli, 151 | ); 152 | 153 | // TODO: tokio::io::stdin is broken (it's blocking) 154 | // see https://github.com/tokio-rs/tokio/issues/589 155 | // let input = tokio::io::stdin(); 156 | let input = crate::async_stdin::Stdin::new(); 157 | 158 | let process = tokio_pty_process_stream::ResizingProcess::new( 159 | tokio_pty_process_stream::Process::new(cmd, args, input), 160 | ); 161 | 162 | let term = vt100::Parser::default(); 163 | let screen = term.screen().clone(); 164 | 165 | Self { 166 | client, 167 | connected: false, 168 | 169 | process, 170 | raw_screen: None, 171 | done: false, 172 | 173 | term, 174 | last_screen: screen, 175 | needs_screen_update: false, 176 | 177 | stdout: tokio::io::stdout(), 178 | to_print: std::collections::VecDeque::new(), 179 | needs_flush: false, 180 | } 181 | } 182 | 183 | fn record_bytes(&mut self, buf: &[u8]) { 184 | self.to_print.extend(buf); 185 | self.term.process(buf); 186 | self.needs_screen_update = true; 187 | } 188 | } 189 | 190 | impl 191 | StreamSession 192 | { 193 | const POLL_FNS: 194 | &'static [&'static dyn for<'a> Fn( 195 | &'a mut Self, 196 | ) 197 | -> component_future::Poll< 198 | (), 199 | Error, 200 | >] = &[ 201 | &Self::poll_read_client, 202 | &Self::poll_read_process, 203 | &Self::poll_write_terminal, 204 | &Self::poll_flush_terminal, 205 | &Self::poll_write_server, 206 | ]; 207 | 208 | // this should never return Err, because we don't want server 209 | // communication issues to ever interrupt a running process 210 | fn poll_read_client(&mut self) -> component_future::Poll<(), Error> { 211 | match self.client.poll() { 212 | Ok(futures::Async::Ready(Some(e))) => match e { 213 | crate::client::Event::Disconnect => { 214 | self.connected = false; 215 | Ok(component_future::Async::DidWork) 216 | } 217 | crate::client::Event::Connect => { 218 | self.connected = true; 219 | self.client.send_message( 220 | crate::protocol::Message::terminal_output( 221 | &self.last_screen.contents_formatted(), 222 | ), 223 | ); 224 | Ok(component_future::Async::DidWork) 225 | } 226 | crate::client::Event::ServerMessage(..) => { 227 | // we don't expect to ever see a server message once we 228 | // start streaming, so if one comes through, assume 229 | // something is messed up and try again 230 | self.client.reconnect(); 231 | Ok(component_future::Async::DidWork) 232 | } 233 | }, 234 | Ok(futures::Async::Ready(None)) => { 235 | // the client should never exit on its own 236 | unreachable!() 237 | } 238 | Ok(futures::Async::NotReady) => { 239 | Ok(component_future::Async::NotReady) 240 | } 241 | Err(..) => { 242 | self.client.reconnect(); 243 | Ok(component_future::Async::DidWork) 244 | } 245 | } 246 | } 247 | 248 | fn poll_read_process(&mut self) -> component_future::Poll<(), Error> { 249 | match component_future::try_ready!(self 250 | .process 251 | .poll() 252 | .context(crate::error::Subprocess)) 253 | { 254 | Some(tokio_pty_process_stream::Event::CommandStart { 255 | .. 256 | }) => { 257 | if self.raw_screen.is_none() { 258 | self.raw_screen = Some( 259 | crossterm::screen::RawScreen::into_raw_mode() 260 | .context(crate::error::ToRawMode)?, 261 | ); 262 | } 263 | } 264 | Some(tokio_pty_process_stream::Event::CommandExit { .. }) => { 265 | self.done = true; 266 | } 267 | Some(tokio_pty_process_stream::Event::Output { data }) => { 268 | self.record_bytes(&data); 269 | } 270 | Some(tokio_pty_process_stream::Event::Resize { 271 | size: (rows, cols), 272 | }) => { 273 | self.term.set_size(rows, cols); 274 | self.client.send_message(crate::protocol::Message::resize( 275 | crate::term::Size { rows, cols }, 276 | )); 277 | } 278 | None => { 279 | if !self.done { 280 | unreachable!() 281 | } 282 | // don't return final event here - wait until we are done 283 | // sending all data to the server (see poll_write_server) 284 | } 285 | } 286 | Ok(component_future::Async::DidWork) 287 | } 288 | 289 | fn poll_write_terminal(&mut self) -> component_future::Poll<(), Error> { 290 | if self.to_print.is_empty() { 291 | return Ok(component_future::Async::NothingToDo); 292 | } 293 | 294 | let (a, b) = self.to_print.as_slices(); 295 | let buf = if a.is_empty() { b } else { a }; 296 | let n = component_future::try_ready!(self 297 | .stdout 298 | .poll_write(buf) 299 | .context(crate::error::WriteTerminal)); 300 | for _ in 0..n { 301 | self.to_print.pop_front(); 302 | } 303 | self.needs_flush = true; 304 | Ok(component_future::Async::DidWork) 305 | } 306 | 307 | fn poll_flush_terminal(&mut self) -> component_future::Poll<(), Error> { 308 | if !self.needs_flush { 309 | return Ok(component_future::Async::NothingToDo); 310 | } 311 | 312 | component_future::try_ready!(self 313 | .stdout 314 | .poll_flush() 315 | .context(crate::error::FlushTerminal)); 316 | self.needs_flush = false; 317 | Ok(component_future::Async::DidWork) 318 | } 319 | 320 | fn poll_write_server(&mut self) -> component_future::Poll<(), Error> { 321 | if !self.connected || !self.needs_screen_update { 322 | // ship all data to the server before actually ending 323 | if self.done { 324 | return Ok(component_future::Async::Ready(())); 325 | } else { 326 | return Ok(component_future::Async::NothingToDo); 327 | } 328 | } 329 | 330 | let screen = self.term.screen().clone(); 331 | self.client 332 | .send_message(crate::protocol::Message::terminal_output( 333 | &screen.contents_diff(&self.last_screen), 334 | )); 335 | self.last_screen = screen; 336 | self.needs_screen_update = false; 337 | 338 | Ok(component_future::Async::DidWork) 339 | } 340 | } 341 | 342 | #[must_use = "futures do nothing unless polled"] 343 | impl 344 | futures::Future for StreamSession 345 | { 346 | type Item = (); 347 | type Error = Error; 348 | 349 | fn poll(&mut self) -> futures::Poll { 350 | component_future::poll_future(self, Self::POLL_FNS) 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # teleterm 2 | 3 | share your terminals! 4 | 5 | ## Overview 6 | 7 | When I was first learning to program, one of the things I did in my spare time 8 | was play NetHack. In particular, I played on the 9 | [nethack.alt.org](https://alt.org/nethack/) public server, and hung out in 10 | \#nethack on IRC. One of the things that made this a great learning environment 11 | was that all games played on this server are automatically recorded and 12 | livestreamed. This allowed you to both watch other people play to pick up tips, 13 | as well as ask other people to look at your game and give you advice. 14 | 15 | After a while, a group of us realized that this model could be used for more 16 | than just playing games, and set up a similar terminal re-broadcaster for 17 | general purpose use. This allowed us to see what other peoples' development 18 | environments and workflows were like in real time, and made collaborating on 19 | projects much more seamless. `teleterm` is an attempt to recreate that 20 | environment that was so helpful in my own learning process, while fixing some 21 | of the issues that the original version had. 22 | 23 | In particular, `teleterm` is intended to be able to be run entirely 24 | transparently (you shouldn't even know it's running while you're streaming), 25 | and you should be able to keep a window open to watch other peoples' terminals 26 | in the corner of your screen without it being disruptive. `teleterm` doesn't 27 | include any functionality to control your local terminal remotely, and doesn't 28 | include any communication functionality (other than the terminal itself) - it 29 | is best used in an already existing community with more featureful 30 | communication methods. 31 | 32 | ## Features 33 | 34 | * Transparently broadcast your terminal session, optionally using TLS 35 | encryption and secure authentication 36 | * Automatically reconnect in the background when you lose internet 37 | connectivity, without the work you're doing in your terminal session being 38 | disrupted 39 | * Record and play back [ttyrec](https://en.wikipedia.org/wiki/Ttyrec) files 40 | 41 | ## Installation 42 | 43 | If you have a working [rust](https://www.rust-lang.org/) installation, 44 | `teleterm` can be installed from source by running `cargo install teleterm`. 45 | Otherwise, we provide prebuilt packages for a couple operating systems: 46 | 47 | ### [Arch Linux](https://git.tozt.net/teleterm/releases/arch/) 48 | 49 | ### [Ubuntu/Debian](https://git.tozt.net/teleterm/releases/deb/) 50 | 51 | All packages are signed, and can be verified with 52 | [minisign](https://jedisct1.github.io/minisign/) using the public key 53 | `RWTM0AZ5RpROOfAIWx1HvYQ6pw1+FKwN6526UFTKNImP/Hz3ynCFst3r`. 54 | 55 | ## Usage 56 | 57 | ### Streaming 58 | 59 | You can start streaming by simply running `tt` (or `tt stream`). It will prompt 60 | you for some information about the server you would like to connect to, and 61 | store that information in a configuration file in your home directory. (Note 62 | that I am not running any publically accessible server, because I believe this 63 | works better as a tool for smaller, already existing communities, so you'll 64 | need to run your own or find someone else to host one first.) 65 | 66 | ### Watching 67 | 68 | To watch existing streams, run `tt watch`. This will display a menu of 69 | currently active streams - select one, and it will be displayed in your 70 | terminal. Press `q` to return to the menu. 71 | 72 | ### Recording 73 | 74 | You can record your terminal session to a file by running `tt record`. This 75 | uses the standard [ttyrec](https://en.wikipedia.org/wiki/Ttyrec) file format, 76 | which can be understood by many different applications (including `tt play`). 77 | Note that both `tt stream` and `tt record` can be given a command to run 78 | instead of just a shell, so you can broadcast your terminal and record the 79 | session to a file at once by running `tt stream tt record`. 80 | 81 | ### Playback 82 | 83 | You can play back previously recorded ttyrec files by using `tt play`. 84 | 85 | ## Configuration 86 | 87 | ### Command line flags 88 | 89 | These are documented via `tt help`. 90 | 91 | ### Environment variables 92 | 93 | `tt` respects the [`RUST_LOG`](https://docs.rs/env_logger/*/env_logger/) 94 | environment variable to adjust the logging verbosity. By default, `tt server` 95 | displays logs at the `info` level and the rest of the commands display logs at 96 | the `error` level, but you can run a command like `RUST_LOG=tt=info tt stream` 97 | to see more information. Note that for interactive commands like `tt stream`, 98 | this will likely be disruptive, but you can send the output to a file by 99 | redirecting `STDERR` (since all process output is written to `tt`'s `STDOUT` 100 | and all log output is written to `tt`'s `STDERR`), like this: `RUST_LOG=tt=info 101 | tt stream 2>>stream.log`. 102 | 103 | ### Configuration file 104 | 105 | `teleterm` also optionally reads configuration from a configuration file. This 106 | file should be in [TOML](https://en.wikipedia.org/wiki/TOML) format, and stored 107 | either in `~/.config/teleterm/config.toml` or `/etc/teleterm/config.toml`. If a 108 | configuration file does not exist, `tt stream` and `tt watch` will offer to 109 | create one for you automatically. The configuration has several sections: 110 | 111 | #### `[server]` (used by `tt server`) 112 | 113 | * `listen_address` 114 | * Local address for the server to listen on, in the format `HOST:PORT`. 115 | * Default: `127.0.0.1:4144` 116 | * `buffer_size` 117 | * Maximum size of the per-connection buffer to maintain, which will be sent 118 | when a new client connects (in order to be able to fully redraw the 119 | current terminal state). 120 | * Default: `4194304` 121 | * `read_timeout` 122 | * Amount of time in seconds to wait without receiving data from a client 123 | before disconnecting that client. Note that besides sending data on 124 | terminal output, clients also send a heartbeat message every 30 seconds 125 | in order to keep the connection alive. 126 | * Default: `120` 127 | * `tls_identity_file` 128 | * If this option is specified, the server will use TLS to encrypt incoming 129 | connections (and clients connecting to this server must enable the `tls` 130 | client option). The value of this option should be the path to a file 131 | containing the TLS private key along with a certificate chain up to a 132 | trusted root, in PKCS #12 format. This file can be generated from an 133 | existing private key and cert chain using a command like this: 134 | ``` 135 | openssl pkcs12 -export -out identity.pfx -inkey key.pem -in cert.pem -certfile chain_certs.pem 136 | ``` 137 | * Default: unset (the server will accept plaintext TCP connections) 138 | * `allowed_login_methods` 139 | * List of login methods to allow from incoming connections. Must be 140 | non-empty. Valid login methods are: 141 | * `plain`: The client supplies a username, which the server uses 142 | directly. Allows impersonation, but can be fine if that's not an 143 | issue for you. 144 | * `recurse_center`: The client authenticates via the 145 | [Recurse Center](https://www.recurse.com/)'s OAuth flow, and 146 | retrieves the user's name from the Recurse Center API. 147 | * Default: `["plain", "recurse_center"]` 148 | * `uid` 149 | * If set and the server is run as `root`, the server will switch to this 150 | username or uid after binding to a port and reading the TLS key. This 151 | allows you to use a low-numbered port or a `root`-owned TLS key without 152 | requiring the server itself to handle connection requests as `root`. 153 | * Default: unset 154 | * `gid` 155 | * Same as `uid`, except sets the user's primary group. 156 | * Default: unset 157 | 158 | #### `[oauth..]` (used by `tt server`) 159 | 160 | `` corresponds to an OAuth-using login method. Currently only 161 | `recurse_center` is supported. `` describes what types of clients will 162 | be using this configuration. Currently valid values for `` are `cli` 163 | (for `tt stream` and `tt watch`) and `web` (for `tt web`). For example, a valid 164 | configuration section will look like `[oauth.recurse_center.cli]`. You will 165 | need to configure separate OAuth applications for `cli` and `web` since the 166 | `redirect_url` will need to be different in each case. 167 | 168 | * `client_id` 169 | * OAuth client id. Required. 170 | * `client_secret` 171 | * OAuth client secret. Required. 172 | 173 | #### `[client]` (used by `tt stream` and `tt watch`) 174 | 175 | * `auth` 176 | * Login method to use (must be one of the methods that the server has been 177 | configured to accept). 178 | * Default: `plain` 179 | * `username` 180 | * If using the `plain` login method, the username to log in as. 181 | * Default: the local username that the `tt` process is running under 182 | (fetched from the `$USER` environment variable) 183 | * `connect_address` 184 | * Address to connect to, in `HOST:PORT` form. Note that when connecting to 185 | a TLS-using server, the `HOST` component must correspond to a name on the 186 | TLS certificate used by the server. 187 | * Default: `127.0.0.1:4144` 188 | * `tls` 189 | * Whether to connect to the server using TLS. 190 | * Default: `false` 191 | 192 | #### `[command]` (used by `tt stream` and `tt record`) 193 | 194 | * `buffer_size` 195 | * Maximum size of the buffer to maintain, which will be sent to the server 196 | when reconnecting after a connection drops (in order to be able to fully 197 | redraw the current terminal state). 198 | * Default: `4194304` 199 | * `command` 200 | * Command to execute. 201 | * Default: the currently running shell (fetched from the `$SHELL` 202 | environment variable) 203 | * `args` 204 | * List of arguments to pass to `command`. 205 | * Default: `[]` 206 | 207 | #### `[ttyrec]` (used by `tt record` and `tt play`) 208 | 209 | * `filename` 210 | * Name of the TTYrec file to save to or read from. 211 | * Default: `teleterm.ttyrec` 212 | 213 | ### OAuth 214 | 215 | `tt` expects OAuth applications to be configured with specific values for the 216 | `redirect_url` setting. In particular: 217 | 218 | * For `cli`, the `redirect_url` should be exactly 219 | `http://localhost:44141/oauth`. 220 | * For `web`, the `redirect_url` should be 221 | `:///oauth/`, where `` is either 222 | `http` or `https` depending on whether your web server has TLS enabled, 223 | `` is the `public_address` value configured in the `[web]` 224 | section, and `` is the authentication method (currently only 225 | `recurse_center` is supported here). 226 | 227 | ## Troubleshooting 228 | 229 | ### I'm trying to watch someone and the output is a garbled mess! 230 | 231 | There are three main causes of this: 232 | 233 | 1. *Your local terminal size is not the same as the terminal size of the person 234 | streaming.* A smaller terminal will almost definitely cause problems here 235 | (and the `tt watch` menu will display the terminal size in red if this is 236 | the case), but a terminal which is too large can also occasionally cause 237 | issues if the person is running a full-screen application that relies on the 238 | details of the terminal's line wrapping behavior. 239 | 2. *Your terminal type is incompatible with the terminal type of the person 240 | streaming.* Different terminals use different escape sequences to represent 241 | various behavior (such as moving the cursor or clearing the screen) and 242 | while many of these are shared across terminals, many also aren't. In this 243 | case, you should switch to using a terminal which is compatible. Note that 244 | `screen` or `tmux` counts as a terminal in this sense, and so an easy fix 245 | here is often to just always run `tt` inside a `screen` or `tmux` session, 246 | both when streaming and watching (and convincing the person you're watching 247 | to do the same). 248 | 3. *The person you are watching has produced a large amount of terminal output 249 | without clearing their screen.* Terminal output is determined by a sequence 250 | of drawing commands (issued via escape sequences) starting from a blank 251 | terminal, and this means that, depending on the output, it can require an 252 | arbitrarily large amount of data to recreate the current terminal 253 | accurately. `teleterm` puts a limit on the amount of data to save, however 254 | (to avoid running out of memory), and so long sequences of output without 255 | screen clears can cause display corruption. This can be fixed by just asking 256 | the streamer to clear their screen (either by running `reset` or `clear` 257 | from the command line, or by using the redraw functionality of the 258 | application they are running, typically bound to something like `^L` or 259 | `^R`). 260 | 261 | ## Contributing 262 | 263 | I'm very interested in contributions! I have a list of todo items in this 264 | repository at TODO.md, but I'm also open to any other patches you think would 265 | make this more useful. Send me an email, or open a ticket or pull request on 266 | Github or Gitlab. 267 | -------------------------------------------------------------------------------- /teleterm/src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, snafu::Snafu)] 2 | #[snafu(visibility = "pub")] 3 | pub enum Error { 4 | #[snafu(display("failed to accept: {}", source))] 5 | Acceptor { source: tokio::io::Error }, 6 | 7 | #[snafu(display( 8 | "oauth configuration for auth type {:?} not found", 9 | ty 10 | ))] 11 | AuthTypeMissingOauthConfig { ty: crate::protocol::AuthType }, 12 | 13 | #[snafu(display("auth type {:?} not allowed", ty))] 14 | AuthTypeNotAllowed { ty: crate::protocol::AuthType }, 15 | 16 | #[snafu(display("auth type {:?} does not use oauth", ty))] 17 | AuthTypeNotOauth { ty: crate::protocol::AuthType }, 18 | 19 | #[snafu(display("failed to bind to {}: {}", address, source))] 20 | Bind { 21 | address: std::net::SocketAddr, 22 | source: tokio::io::Error, 23 | }, 24 | 25 | #[snafu(display("config file {} doesn't exist", name))] 26 | ConfigFileDoesntExist { name: String }, 27 | 28 | #[snafu(display("failed to connect to {}: {}", address, source))] 29 | Connect { 30 | address: std::net::SocketAddr, 31 | source: std::io::Error, 32 | }, 33 | 34 | #[snafu(display( 35 | "failed to make tls connection to {}: {}", 36 | host, 37 | source 38 | ))] 39 | ConnectTls { 40 | host: String, 41 | source: native_tls::Error, 42 | }, 43 | 44 | #[snafu(display("couldn't determine the current username"))] 45 | CouldntFindUsername, 46 | 47 | #[snafu(display("failed to parse configuration: {}", source))] 48 | CouldntParseConfig { source: config::ConfigError }, 49 | 50 | #[snafu(display("failed to create tls acceptor: {}", source))] 51 | CreateAcceptor { source: native_tls::Error }, 52 | 53 | #[snafu(display("failed to create tls connector: {}", source))] 54 | CreateConnector { source: native_tls::Error }, 55 | 56 | #[snafu(display("failed to create directory {}: {}", filename, source))] 57 | CreateDir { 58 | filename: String, 59 | source: std::io::Error, 60 | }, 61 | 62 | #[snafu(display("failed to create file {}: {}", filename, source))] 63 | CreateFile { 64 | filename: String, 65 | source: tokio::io::Error, 66 | }, 67 | 68 | #[snafu(display("failed to create file {}: {}", filename, source))] 69 | CreateFileSync { 70 | filename: String, 71 | source: std::io::Error, 72 | }, 73 | 74 | #[snafu(display("received EOF from server"))] 75 | EOF, 76 | 77 | #[snafu(display( 78 | "failed to retrieve access token from authorization code: {:?}", 79 | msg 80 | ))] 81 | ExchangeCode { 82 | msg: String, 83 | // XXX RequestTokenError doesn't implement the right traits 84 | // source: oauth2::RequestTokenError< 85 | // oauth2::reqwest::Error, 86 | // oauth2::StandardErrorResponse< 87 | // oauth2::basic::BasicErrorResponseType, 88 | // >, 89 | // > 90 | }, 91 | 92 | #[snafu(display( 93 | "failed to retrieve access token from refresh token: {:?}", 94 | msg 95 | ))] 96 | ExchangeRefreshToken { 97 | msg: String, 98 | // XXX RequestTokenError doesn't implement the right traits 99 | // source: oauth2::RequestTokenError< 100 | // oauth2::reqwest::Error, 101 | // oauth2::StandardErrorResponse< 102 | // oauth2::basic::BasicErrorResponseType, 103 | // >, 104 | // > 105 | }, 106 | 107 | #[snafu(display( 108 | "failed to parse string {:?}: unexpected trailing data", 109 | data 110 | ))] 111 | ExtraMessageData { data: Vec }, 112 | 113 | #[snafu(display("failed to write to stdout: {}", source))] 114 | FlushTerminal { source: tokio::io::Error }, 115 | 116 | #[snafu(display( 117 | "failed to get recurse center profile data: {}", 118 | source 119 | ))] 120 | GetRecurseCenterProfile { source: reqwest::Error }, 121 | 122 | #[snafu(display("failed to get terminal size: {}", source))] 123 | GetTerminalSize { source: crossterm::ErrorKind }, 124 | 125 | #[snafu(display("failed to find any resolvable addresses"))] 126 | HasResolvedAddr, 127 | 128 | #[snafu(display("invalid auth client {}", ty))] 129 | InvalidAuthClient { ty: u8 }, 130 | 131 | #[snafu(display("invalid auth client {}", ty))] 132 | InvalidAuthClientStr { ty: String }, 133 | 134 | #[snafu(display("invalid auth type {}", ty))] 135 | InvalidAuthType { ty: u8 }, 136 | 137 | #[snafu(display("invalid auth type {}", ty))] 138 | InvalidAuthTypeStr { ty: String }, 139 | 140 | #[snafu(display("invalid message type {}", ty))] 141 | InvalidMessageType { ty: u8 }, 142 | 143 | #[snafu(display("invalid watch id {}", id))] 144 | InvalidWatchId { id: String }, 145 | 146 | #[snafu(display( 147 | "packet length must be at least {} bytes (got {})", 148 | expected, 149 | len 150 | ))] 151 | LenTooSmall { len: u32, expected: usize }, 152 | 153 | #[snafu(display( 154 | "packet length must be at most {} bytes (got {})", 155 | expected, 156 | len 157 | ))] 158 | LenTooBig { len: u32, expected: usize }, 159 | 160 | #[snafu(display("couldn't find name in argv"))] 161 | MissingArgv, 162 | 163 | #[snafu(display( 164 | "detected argv path {} was not a valid filename", 165 | path 166 | ))] 167 | NotAFileName { path: String }, 168 | 169 | #[snafu(display( 170 | "missing oauth configuration item {} for section oauth.{}.{}", 171 | field, 172 | auth_type.name(), 173 | auth_client.name(), 174 | ))] 175 | OauthMissingConfiguration { 176 | field: String, 177 | auth_type: crate::protocol::AuthType, 178 | auth_client: crate::protocol::AuthClient, 179 | }, 180 | 181 | #[snafu(display("failed to open file {}: {}", filename, source))] 182 | OpenFile { 183 | filename: String, 184 | source: tokio::io::Error, 185 | }, 186 | 187 | #[snafu(display("failed to open file {}: {}", filename, source))] 188 | OpenFileSync { 189 | filename: String, 190 | source: std::io::Error, 191 | }, 192 | 193 | #[snafu(display("failed to open link in browser: {}", source))] 194 | OpenLink { source: std::io::Error }, 195 | 196 | #[snafu(display("failed to parse address"))] 197 | ParseAddress, 198 | 199 | #[snafu(display("failed to parse address: {}", source))] 200 | ParseAddr { source: std::net::AddrParseError }, 201 | 202 | #[snafu(display("{}", source))] 203 | ParseArgs { source: clap::Error }, 204 | 205 | #[snafu(display("failed to parse buffer size {}: {}", input, source))] 206 | ParseBufferSize { 207 | input: String, 208 | source: std::num::ParseIntError, 209 | }, 210 | 211 | #[snafu(display("failed to parse config file: {}", source))] 212 | ParseConfigFile { source: config::ConfigError }, 213 | 214 | #[snafu(display("failed to parse incoming http request"))] 215 | ParseHttpRequest, 216 | 217 | #[snafu(display( 218 | "failed to validate csrf token on incoming http request" 219 | ))] 220 | ParseHttpRequestCsrf, 221 | 222 | #[snafu(display( 223 | "incoming http request had no code in the query parameters" 224 | ))] 225 | ParseHttpRequestMissingCode, 226 | 227 | #[snafu(display( 228 | "failed to parse path from incoming http request: {}", 229 | source 230 | ))] 231 | ParseHttpRequestPath { source: url::ParseError }, 232 | 233 | #[snafu(display("failed to parse identity file: {}", source))] 234 | ParseIdentity { source: native_tls::Error }, 235 | 236 | #[snafu(display( 237 | "failed to parse int from buffer {:?}: {}", 238 | buf, 239 | source 240 | ))] 241 | ParseInt { 242 | buf: Vec, 243 | source: std::array::TryFromSliceError, 244 | }, 245 | 246 | #[snafu(display("failed to parse float option {}: {}", name, source))] 247 | ParseFloat { 248 | name: String, 249 | source: std::num::ParseFloatError, 250 | }, 251 | 252 | #[snafu(display("failed to parse response json: {}", source))] 253 | ParseJson { source: reqwest::Error }, 254 | 255 | #[snafu(display("failed to parse max frame length: {}", source))] 256 | ParseMaxFrameLength { source: std::num::ParseIntError }, 257 | 258 | #[snafu(display( 259 | "failed to parse port {} from address: {}", 260 | string, 261 | source 262 | ))] 263 | ParsePort { 264 | string: String, 265 | source: std::num::ParseIntError, 266 | }, 267 | 268 | #[snafu(display("failed to parse read timeout {}: {}", input, source))] 269 | ParseReadTimeout { 270 | input: String, 271 | source: std::num::ParseIntError, 272 | }, 273 | 274 | #[snafu(display("failed to parse string {:?}: {}", string, source))] 275 | ParseString { 276 | string: Vec, 277 | source: std::string::FromUtf8Error, 278 | }, 279 | 280 | #[snafu(display("rate limit exceeded"))] 281 | RateLimited, 282 | 283 | #[snafu(display("failed to read from event channel: {}", source))] 284 | ReadChannel { 285 | source: tokio::sync::mpsc::error::UnboundedRecvError, 286 | }, 287 | 288 | #[snafu(display("failed to read from file: {}", source))] 289 | ReadFile { source: tokio::io::Error }, 290 | 291 | #[snafu(display("failed to read from file: {}", source))] 292 | ReadFileSync { source: std::io::Error }, 293 | 294 | #[snafu(display("{}", source))] 295 | ReadMessageWithTimeout { 296 | #[snafu(source(from(tokio::timer::timeout::Error, Box::new)))] 297 | source: Box>, 298 | }, 299 | 300 | #[snafu(display("failed to read packet: {}", source))] 301 | ReadPacket { source: tokio::io::Error }, 302 | 303 | #[snafu(display("failed to read from socket: {}", source))] 304 | ReadSocket { source: tokio::io::Error }, 305 | 306 | #[snafu(display("failed to read from terminal: {}", source))] 307 | ReadTerminal { source: std::io::Error }, 308 | 309 | #[snafu(display("failed to read ttyrec: {}", source))] 310 | ReadTtyrec { source: ttyrec::Error }, 311 | 312 | #[snafu(display("failed to poll for terminal resizing: {}", source))] 313 | Resize { 314 | source: tokio_terminal_resize::Error, 315 | }, 316 | 317 | #[snafu(display( 318 | "failed to resolve address {}:{}: {}", 319 | host, 320 | port, 321 | source 322 | ))] 323 | ResolveAddress { 324 | host: String, 325 | port: u16, 326 | source: std::io::Error, 327 | }, 328 | 329 | #[snafu(display("failed to serialize message as json: {}", source))] 330 | SerializeMessage { source: serde_json::Error }, 331 | 332 | #[snafu(display("received error from server: {}", message))] 333 | Server { message: String }, 334 | 335 | #[snafu(display("couldn't connect to server"))] 336 | ServerDisconnected, 337 | 338 | #[snafu(display("SIGWINCH handler failed: {}", source))] 339 | SigWinchHandler { source: std::io::Error }, 340 | 341 | #[snafu(display("failed to sleep until next frame: {}", source))] 342 | Sleep { source: tokio::timer::Error }, 343 | 344 | #[snafu(display( 345 | "failed to receive new socket over channel: channel closed" 346 | ))] 347 | SocketChannelClosed, 348 | 349 | #[snafu(display( 350 | "failed to receive new socket over channel: {}", 351 | source 352 | ))] 353 | SocketChannelReceive { 354 | source: tokio::sync::mpsc::error::RecvError, 355 | }, 356 | 357 | #[snafu(display("poll subprocess failed: {}", source))] 358 | Subprocess { 359 | source: tokio_pty_process_stream::Error, 360 | }, 361 | 362 | #[snafu(display("failed to switch gid: {}", source))] 363 | SwitchGid { source: std::io::Error }, 364 | 365 | #[snafu(display("failed to switch uid: {}", source))] 366 | SwitchUid { source: std::io::Error }, 367 | 368 | #[snafu(display( 369 | "failed to spawn a background thread to read terminal input: {}", 370 | source 371 | ))] 372 | TerminalInputReadingThread { source: std::io::Error }, 373 | 374 | #[snafu(display( 375 | "terminal must be smaller than 1000 rows or columns (got {})", 376 | size 377 | ))] 378 | TermTooBig { size: crate::term::Size }, 379 | 380 | #[snafu(display("timeout"))] 381 | Timeout, 382 | 383 | #[snafu(display("heartbeat timer failed: {}", source))] 384 | TimerHeartbeat { source: tokio::timer::Error }, 385 | 386 | #[snafu(display("read timeout timer failed: {}", source))] 387 | TimerReadTimeout { source: tokio::timer::Error }, 388 | 389 | #[snafu(display("reconnect timer failed: {}", source))] 390 | TimerReconnect { source: tokio::timer::Error }, 391 | 392 | #[snafu(display("failed to switch to alternate screen: {}", source))] 393 | ToAlternateScreen { source: crossterm::ErrorKind }, 394 | 395 | #[snafu(display( 396 | "failed to put the terminal into raw mode: {}", 397 | source 398 | ))] 399 | ToRawMode { source: crossterm::ErrorKind }, 400 | 401 | #[snafu(display("unauthenticated message: {:?}", message))] 402 | UnauthenticatedMessage { message: crate::protocol::Message }, 403 | 404 | #[snafu(display("unexpected message: {:?}", message))] 405 | UnexpectedMessage { message: crate::protocol::Message }, 406 | 407 | #[snafu(display("failed to find group with gid {}", gid))] 408 | UnknownGid { gid: users::gid_t }, 409 | 410 | #[snafu(display("failed to find group with group name {}", name))] 411 | UnknownGroup { name: String }, 412 | 413 | #[snafu(display("failed to find user with uid {}", uid))] 414 | UnknownUid { uid: users::uid_t }, 415 | 416 | #[snafu(display("failed to find user with username {}", name))] 417 | UnknownUser { name: String }, 418 | 419 | #[snafu(display("failure during websocket stream: {}", source))] 420 | WebSocket { 421 | source: tokio_tungstenite::tungstenite::Error, 422 | }, 423 | 424 | #[snafu(display("failed to accept websocket connection: {}", source))] 425 | WebSocketAccept { source: hyper::Error }, 426 | 427 | #[snafu(display("failed to write to file: {}", source))] 428 | WriteFile { source: tokio::io::Error }, 429 | 430 | #[snafu(display("failed to write to file: {}", source))] 431 | WriteFileSync { source: std::io::Error }, 432 | 433 | #[snafu(display("{}", source))] 434 | WriteMessageWithTimeout { 435 | #[snafu(source(from(tokio::timer::timeout::Error, Box::new)))] 436 | source: Box>, 437 | }, 438 | 439 | #[snafu(display("failed to write packet: {}", source))] 440 | WritePacket { source: tokio::io::Error }, 441 | 442 | #[snafu(display("failed to write to socket: {}", source))] 443 | WriteSocket { source: tokio::io::Error }, 444 | 445 | #[snafu(display("failed to write to stdout: {}", source))] 446 | WriteTerminal { source: tokio::io::Error }, 447 | 448 | #[snafu(display("failed to write to terminal: {}", source))] 449 | WriteTerminalCrossterm { source: crossterm::ErrorKind }, 450 | 451 | #[snafu(display("failed to write ttyrec: {}", source))] 452 | WriteTtyrec { source: ttyrec::Error }, 453 | } 454 | 455 | pub type Result = std::result::Result; 456 | -------------------------------------------------------------------------------- /teleterm/src/client.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::*; 2 | use rand::Rng as _; 3 | use std::io::Read as _; 4 | 5 | const HEARTBEAT_DURATION: std::time::Duration = 6 | std::time::Duration::from_secs(30); 7 | const RECONNECT_BACKOFF_BASE: std::time::Duration = 8 | std::time::Duration::from_secs(1); 9 | const RECONNECT_BACKOFF_FACTOR: f32 = 2.0; 10 | const RECONNECT_BACKOFF_MAX: std::time::Duration = 11 | std::time::Duration::from_secs(60); 12 | 13 | const OAUTH_LISTEN_ADDRESS: &str = "127.0.0.1:44141"; 14 | const OAUTH_BROWSER_SUCCESS_MESSAGE: &str = "authenticated successfully! now close this page and return to your terminal."; 15 | 16 | enum ReadSocket< 17 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 18 | > { 19 | NotConnected, 20 | Connected(crate::protocol::FramedReadHalf), 21 | Reading( 22 | Box< 23 | dyn futures::Future< 24 | Item = ( 25 | crate::protocol::Message, 26 | crate::protocol::FramedReadHalf, 27 | ), 28 | Error = Error, 29 | > + Send, 30 | >, 31 | ), 32 | Processing( 33 | crate::protocol::FramedReadHalf, 34 | Box< 35 | dyn futures::Future< 36 | Item = crate::protocol::Message, 37 | Error = Error, 38 | > + Send, 39 | >, 40 | ), 41 | } 42 | 43 | enum WriteSocket< 44 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 45 | > { 46 | NotConnected, 47 | Connecting( 48 | Box< 49 | dyn futures::Future + Send, 50 | >, 51 | ), 52 | Connected(crate::protocol::FramedWriteHalf), 53 | Writing( 54 | Box< 55 | dyn futures::Future< 56 | Item = crate::protocol::FramedWriteHalf, 57 | Error = Error, 58 | > + Send, 59 | >, 60 | ), 61 | } 62 | 63 | pub enum Event { 64 | ServerMessage(crate::protocol::Message), 65 | Disconnect, 66 | Connect, 67 | } 68 | 69 | pub type Connector = Box< 70 | dyn Fn() -> Box< 71 | dyn futures::Future + Send, 72 | > + Send, 73 | >; 74 | 75 | pub struct Client< 76 | S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, 77 | > { 78 | connect: Connector, 79 | auth: crate::protocol::Auth, 80 | auth_client: crate::protocol::AuthClient, 81 | 82 | term_type: String, 83 | 84 | heartbeat_timer: tokio::timer::Interval, 85 | reconnect_timer: Option, 86 | reconnect_backoff_amount: std::time::Duration, 87 | last_server_time: std::time::Instant, 88 | 89 | rsock: ReadSocket, 90 | wsock: WriteSocket, 91 | 92 | // `raw` means to just connect and send Login, then forward all messages 93 | // as ServerMessage events rather than handling connection messages 94 | // internally. Connect and Disconnect events will not be sent. 95 | raw: bool, 96 | on_login: Vec, 97 | to_send: std::collections::VecDeque, 98 | 99 | last_error: Option, 100 | } 101 | 102 | impl 103 | Client 104 | { 105 | pub fn stream( 106 | term_type: &str, 107 | connect: Connector, 108 | auth: &crate::protocol::Auth, 109 | auth_client: crate::protocol::AuthClient, 110 | ) -> Self { 111 | Self::new( 112 | term_type, 113 | connect, 114 | auth, 115 | auth_client, 116 | &[crate::protocol::Message::start_streaming()], 117 | false, 118 | ) 119 | } 120 | 121 | pub fn watch( 122 | term_type: &str, 123 | connect: Connector, 124 | auth: &crate::protocol::Auth, 125 | auth_client: crate::protocol::AuthClient, 126 | id: &str, 127 | ) -> Self { 128 | Self::new( 129 | term_type, 130 | connect, 131 | auth, 132 | auth_client, 133 | &[crate::protocol::Message::start_watching(id)], 134 | false, 135 | ) 136 | } 137 | 138 | pub fn list( 139 | term_type: &str, 140 | connect: Connector, 141 | auth: &crate::protocol::Auth, 142 | auth_client: crate::protocol::AuthClient, 143 | ) -> Self { 144 | Self::new(term_type, connect, auth, auth_client, &[], false) 145 | } 146 | 147 | pub fn raw( 148 | term_type: &str, 149 | connect: Connector, 150 | auth: &crate::protocol::Auth, 151 | auth_client: crate::protocol::AuthClient, 152 | ) -> Self { 153 | Self::new(term_type, connect, auth, auth_client, &[], true) 154 | } 155 | 156 | fn new( 157 | term_type: &str, 158 | connect: Connector, 159 | auth: &crate::protocol::Auth, 160 | auth_client: crate::protocol::AuthClient, 161 | on_login: &[crate::protocol::Message], 162 | raw: bool, 163 | ) -> Self { 164 | let heartbeat_timer = 165 | tokio::timer::Interval::new_interval(HEARTBEAT_DURATION); 166 | 167 | Self { 168 | connect, 169 | auth: auth.clone(), 170 | auth_client, 171 | 172 | term_type: term_type.to_string(), 173 | 174 | heartbeat_timer, 175 | reconnect_timer: None, 176 | reconnect_backoff_amount: RECONNECT_BACKOFF_BASE, 177 | last_server_time: std::time::Instant::now(), 178 | 179 | rsock: ReadSocket::NotConnected, 180 | wsock: WriteSocket::NotConnected, 181 | 182 | raw, 183 | on_login: on_login.to_vec(), 184 | to_send: std::collections::VecDeque::new(), 185 | 186 | last_error: None, 187 | } 188 | } 189 | 190 | pub fn send_message(&mut self, msg: crate::protocol::Message) { 191 | self.to_send.push_back(msg); 192 | } 193 | 194 | pub fn reconnect(&mut self) { 195 | self.rsock = ReadSocket::NotConnected; 196 | self.wsock = WriteSocket::NotConnected; 197 | } 198 | 199 | pub fn last_error(&self) -> Option<&str> { 200 | self.last_error.as_ref().map(std::string::String::as_str) 201 | } 202 | 203 | fn set_reconnect_timer(&mut self) { 204 | let delay = rand::thread_rng().gen_range( 205 | self.reconnect_backoff_amount / 2, 206 | self.reconnect_backoff_amount, 207 | ); 208 | let delay = delay.max(RECONNECT_BACKOFF_BASE); 209 | self.reconnect_timer = 210 | Some(tokio::timer::Delay::new(std::time::Instant::now() + delay)); 211 | self.reconnect_backoff_amount = self 212 | .reconnect_backoff_amount 213 | .mul_f32(RECONNECT_BACKOFF_FACTOR); 214 | self.reconnect_backoff_amount = 215 | self.reconnect_backoff_amount.min(RECONNECT_BACKOFF_MAX); 216 | } 217 | 218 | fn reset_reconnect_timer(&mut self) { 219 | self.reconnect_timer = None; 220 | self.reconnect_backoff_amount = RECONNECT_BACKOFF_BASE; 221 | } 222 | 223 | fn has_seen_server_recently(&self) -> bool { 224 | let since_last_server = 225 | std::time::Instant::now().duration_since(self.last_server_time); 226 | if since_last_server > HEARTBEAT_DURATION * 2 { 227 | return false; 228 | } 229 | 230 | true 231 | } 232 | 233 | fn handle_successful_connection(&mut self, s: S) -> Result<()> { 234 | self.last_server_time = std::time::Instant::now(); 235 | 236 | log::info!("connected to server"); 237 | 238 | let (rs, ws) = s.split(); 239 | self.rsock = 240 | ReadSocket::Connected(crate::protocol::FramedReader::new(rs)); 241 | self.wsock = 242 | WriteSocket::Connected(crate::protocol::FramedWriter::new(ws)); 243 | 244 | self.to_send.clear(); 245 | self.send_message(crate::protocol::Message::login( 246 | &self.auth, 247 | self.auth_client, 248 | &self.term_type, 249 | crate::term::Size::get()?, 250 | )); 251 | 252 | Ok(()) 253 | } 254 | 255 | fn handle_message( 256 | &mut self, 257 | msg: crate::protocol::Message, 258 | ) -> Result<( 259 | component_future::Async>, 260 | Option< 261 | Box< 262 | dyn futures::Future< 263 | Item = crate::protocol::Message, 264 | Error = Error, 265 | > + Send, 266 | >, 267 | >, 268 | )> { 269 | log::debug!("recv_message({})", msg.format_log()); 270 | 271 | if !self.raw { 272 | match msg { 273 | crate::protocol::Message::OauthCliRequest { url, id } => { 274 | let mut state = None; 275 | let parsed_url = url::Url::parse(&url).unwrap(); 276 | for (k, v) in parsed_url.query_pairs() { 277 | if k == "state" { 278 | state = Some(v); 279 | } 280 | } 281 | open::that(url).context(crate::error::OpenLink)?; 282 | return Ok(( 283 | component_future::Async::DidWork, 284 | Some(self.wait_for_oauth_response( 285 | state.map(|s| s.to_string()), 286 | &id, 287 | )?), 288 | )); 289 | } 290 | crate::protocol::Message::LoggedIn { username } => { 291 | log::info!( 292 | "successfully logged into server as {}", 293 | username 294 | ); 295 | self.reset_reconnect_timer(); 296 | for msg in &self.on_login { 297 | self.to_send.push_back(msg.clone()); 298 | } 299 | self.last_error = None; 300 | return Ok(( 301 | component_future::Async::Ready(Some(Event::Connect)), 302 | None, 303 | )); 304 | } 305 | crate::protocol::Message::Heartbeat => { 306 | return Ok((component_future::Async::DidWork, None)); 307 | } 308 | _ => {} 309 | } 310 | } 311 | Ok(( 312 | component_future::Async::Ready(Some(Event::ServerMessage(msg))), 313 | None, 314 | )) 315 | } 316 | 317 | fn wait_for_oauth_response( 318 | &self, 319 | state: Option, 320 | id: &str, 321 | ) -> Result< 322 | Box< 323 | dyn futures::Future< 324 | Item = crate::protocol::Message, 325 | Error = Error, 326 | > + Send, 327 | >, 328 | > { 329 | lazy_static::lazy_static! { 330 | static ref RE: regex::Regex = regex::Regex::new( 331 | r"^GET (/[^ ]*) HTTP/[0-9.]+$" 332 | ).unwrap(); 333 | } 334 | 335 | let auth_type = self.auth.auth_type(); 336 | let id = id.to_string(); 337 | let address = OAUTH_LISTEN_ADDRESS 338 | .parse() 339 | .context(crate::error::ParseAddr)?; 340 | let listener = tokio::net::TcpListener::bind(&address) 341 | .context(crate::error::Bind { address })?; 342 | Ok(Box::new( 343 | listener 344 | .incoming() 345 | .into_future() 346 | .map_err(|(e, _)| e) 347 | .context(crate::error::Acceptor) 348 | .and_then(|(sock, _)| { 349 | let sock = sock.unwrap(); 350 | tokio::io::lines(std::io::BufReader::new(sock)) 351 | .into_future() 352 | .map_err(|(e, _)| e) 353 | .context(crate::error::ReadSocket) 354 | }) 355 | .and_then(move |(buf, lines)| { 356 | let buf = buf.unwrap(); 357 | let path = &RE 358 | .captures(&buf) 359 | .context(crate::error::ParseHttpRequest)?[1]; 360 | let base = url::Url::parse(&format!( 361 | "http://{}", 362 | OAUTH_LISTEN_ADDRESS 363 | )) 364 | .unwrap(); 365 | let url = base 366 | .join(path) 367 | .context(crate::error::ParseHttpRequestPath)?; 368 | let mut req_code = None; 369 | let mut req_state = None; 370 | for (k, v) in url.query_pairs() { 371 | if k == "code" { 372 | req_code = Some(v.to_string()); 373 | } 374 | if k == "state" { 375 | req_state = Some(v.to_string()); 376 | } 377 | } 378 | if state != req_state { 379 | return Err(Error::ParseHttpRequestCsrf); 380 | } 381 | let code = if let Some(code) = req_code { 382 | code 383 | } else { 384 | return Err(Error::ParseHttpRequestMissingCode); 385 | }; 386 | Ok(( 387 | crate::protocol::Message::oauth_cli_response(&code), 388 | lines.into_inner().into_inner(), 389 | )) 390 | }) 391 | .and_then(move |(msg, sock)| { 392 | save_client_auth_id(auth_type, &id).map(|_| (msg, sock)) 393 | }) 394 | .and_then(|(msg, sock)| { 395 | let response = format!( 396 | "HTTP/1.1 200 OK\n\n{}", 397 | OAUTH_BROWSER_SUCCESS_MESSAGE 398 | ); 399 | tokio::io::write_all(sock, response) 400 | .context(crate::error::WriteSocket) 401 | .map(|_| msg) 402 | }), 403 | )) 404 | } 405 | } 406 | 407 | impl 408 | Client 409 | { 410 | // XXX rustfmt does a terrible job here 411 | const POLL_FNS: 412 | &'static [&'static dyn for<'a> Fn( 413 | &'a mut Self, 414 | ) 415 | -> component_future::Poll< 416 | Option, 417 | Error, 418 | >] = &[ 419 | &Self::poll_reconnect_server, 420 | &Self::poll_read_server, 421 | &Self::poll_write_server, 422 | &Self::poll_heartbeat, 423 | ]; 424 | 425 | fn poll_reconnect_server( 426 | &mut self, 427 | ) -> component_future::Poll, Error> { 428 | match &mut self.wsock { 429 | WriteSocket::NotConnected => { 430 | if let Some(timer) = &mut self.reconnect_timer { 431 | component_future::try_ready!(timer 432 | .poll() 433 | .context(crate::error::TimerReconnect)); 434 | } 435 | 436 | self.set_reconnect_timer(); 437 | self.wsock = WriteSocket::Connecting((self.connect)()); 438 | } 439 | WriteSocket::Connecting(ref mut fut) => match fut.poll() { 440 | Ok(futures::Async::Ready(s)) => { 441 | self.handle_successful_connection(s)?; 442 | } 443 | Ok(futures::Async::NotReady) => { 444 | return Ok(component_future::Async::NotReady); 445 | } 446 | Err(e) => { 447 | if self.raw { 448 | return Err(e); 449 | } 450 | 451 | log::warn!("error while connecting, reconnecting: {}", e); 452 | self.reconnect(); 453 | self.last_error = Some(format!("{}", e)); 454 | return Ok(component_future::Async::Ready(Some( 455 | Event::Disconnect, 456 | ))); 457 | } 458 | }, 459 | WriteSocket::Connected(..) | WriteSocket::Writing(..) => { 460 | if self.has_seen_server_recently() || self.raw { 461 | return Ok(component_future::Async::NothingToDo); 462 | } else { 463 | log::warn!( 464 | "haven't seen server in a while, reconnecting", 465 | ); 466 | self.reconnect(); 467 | self.last_error = 468 | Some("haven't seen server in a while".to_string()); 469 | return Ok(component_future::Async::Ready(Some( 470 | Event::Disconnect, 471 | ))); 472 | } 473 | } 474 | } 475 | 476 | Ok(component_future::Async::DidWork) 477 | } 478 | 479 | fn poll_read_server( 480 | &mut self, 481 | ) -> component_future::Poll, Error> { 482 | match &mut self.rsock { 483 | ReadSocket::NotConnected => { 484 | Ok(component_future::Async::NothingToDo) 485 | } 486 | ReadSocket::Connected(..) => { 487 | if let ReadSocket::Connected(s) = std::mem::replace( 488 | &mut self.rsock, 489 | ReadSocket::NotConnected, 490 | ) { 491 | let fut = crate::protocol::Message::read_async(s); 492 | self.rsock = ReadSocket::Reading(Box::new(fut)); 493 | } else { 494 | unreachable!() 495 | } 496 | Ok(component_future::Async::DidWork) 497 | } 498 | ReadSocket::Reading(ref mut fut) => match fut.poll() { 499 | Ok(futures::Async::Ready((msg, s))) => { 500 | self.last_server_time = std::time::Instant::now(); 501 | match self.handle_message(msg) { 502 | Ok((poll, fut)) => { 503 | if let Some(fut) = fut { 504 | self.rsock = ReadSocket::Processing(s, fut); 505 | } else { 506 | self.rsock = ReadSocket::Connected(s); 507 | } 508 | Ok(poll) 509 | } 510 | Err(e) => { 511 | if self.raw { 512 | return Err(e); 513 | } 514 | 515 | log::warn!( 516 | "error handling message, reconnecting: {}", 517 | e 518 | ); 519 | self.reconnect(); 520 | self.last_error = Some(format!("{}", e)); 521 | Ok(component_future::Async::Ready(Some( 522 | Event::Disconnect, 523 | ))) 524 | } 525 | } 526 | } 527 | Ok(futures::Async::NotReady) => { 528 | Ok(component_future::Async::NotReady) 529 | } 530 | Err(e) => { 531 | if self.raw { 532 | return Err(e); 533 | } 534 | 535 | log::warn!("error reading message, reconnecting: {}", e); 536 | self.reconnect(); 537 | self.last_error = Some(format!("{}", e)); 538 | Ok(component_future::Async::Ready(Some( 539 | Event::Disconnect, 540 | ))) 541 | } 542 | }, 543 | ReadSocket::Processing(_, fut) => match fut.poll() { 544 | Ok(futures::Async::Ready(msg)) => { 545 | if let ReadSocket::Processing(s, _) = std::mem::replace( 546 | &mut self.rsock, 547 | ReadSocket::NotConnected, 548 | ) { 549 | self.rsock = ReadSocket::Connected(s); 550 | self.send_message(msg); 551 | } else { 552 | unreachable!() 553 | } 554 | Ok(component_future::Async::DidWork) 555 | } 556 | Ok(futures::Async::NotReady) => { 557 | Ok(component_future::Async::NotReady) 558 | } 559 | Err(e) => { 560 | if self.raw { 561 | return Err(e); 562 | } 563 | 564 | log::warn!( 565 | "error processing message, reconnecting: {}", 566 | e 567 | ); 568 | self.reconnect(); 569 | self.last_error = Some(format!("{}", e)); 570 | Ok(component_future::Async::Ready(Some( 571 | Event::Disconnect, 572 | ))) 573 | } 574 | }, 575 | } 576 | } 577 | 578 | fn poll_write_server( 579 | &mut self, 580 | ) -> component_future::Poll, Error> { 581 | match &mut self.wsock { 582 | WriteSocket::NotConnected | WriteSocket::Connecting(..) => { 583 | Ok(component_future::Async::NothingToDo) 584 | } 585 | WriteSocket::Connected(..) => { 586 | if self.to_send.is_empty() { 587 | return Ok(component_future::Async::NothingToDo); 588 | } 589 | 590 | if let WriteSocket::Connected(s) = std::mem::replace( 591 | &mut self.wsock, 592 | WriteSocket::NotConnected, 593 | ) { 594 | let msg = self.to_send.pop_front().unwrap(); 595 | log::debug!("send_message({})", msg.format_log()); 596 | let fut = msg.write_async(s); 597 | self.wsock = WriteSocket::Writing(Box::new(fut)); 598 | } else { 599 | unreachable!() 600 | } 601 | 602 | Ok(component_future::Async::DidWork) 603 | } 604 | WriteSocket::Writing(ref mut fut) => match fut.poll() { 605 | Ok(futures::Async::Ready(s)) => { 606 | self.wsock = WriteSocket::Connected(s); 607 | Ok(component_future::Async::DidWork) 608 | } 609 | Ok(futures::Async::NotReady) => { 610 | Ok(component_future::Async::NotReady) 611 | } 612 | Err(e) => { 613 | if self.raw { 614 | return Err(e); 615 | } 616 | 617 | log::warn!("error writing message, reconnecting: {}", e); 618 | self.reconnect(); 619 | self.last_error = Some(format!("{}", e)); 620 | Ok(component_future::Async::Ready(Some( 621 | Event::Disconnect, 622 | ))) 623 | } 624 | }, 625 | } 626 | } 627 | 628 | fn poll_heartbeat( 629 | &mut self, 630 | ) -> component_future::Poll, Error> { 631 | let _ = component_future::try_ready!(self 632 | .heartbeat_timer 633 | .poll() 634 | .context(crate::error::TimerHeartbeat)); 635 | self.send_message(crate::protocol::Message::heartbeat()); 636 | Ok(component_future::Async::DidWork) 637 | } 638 | } 639 | 640 | #[must_use = "streams do nothing unless polled"] 641 | impl 642 | futures::Stream for Client 643 | { 644 | type Item = Event; 645 | type Error = Error; 646 | 647 | fn poll(&mut self) -> futures::Poll, Self::Error> { 648 | component_future::poll_stream(self, Self::POLL_FNS) 649 | } 650 | } 651 | 652 | pub fn load_client_auth_id( 653 | auth: crate::protocol::AuthType, 654 | ) -> Option { 655 | client_id_file(auth, true).and_then(|id_file| { 656 | std::fs::File::open(id_file).ok().and_then(|mut file| { 657 | let mut id = vec![]; 658 | file.read_to_end(&mut id).ok().map(|_| { 659 | std::string::String::from_utf8_lossy(&id).to_string() 660 | }) 661 | }) 662 | }) 663 | } 664 | 665 | fn save_client_auth_id( 666 | auth: crate::protocol::AuthType, 667 | id: &str, 668 | ) -> impl futures::Future { 669 | let id_file = client_id_file(auth, false).unwrap(); 670 | let id = id.to_string(); 671 | tokio::fs::File::create(id_file.clone()) 672 | .with_context(move || crate::error::CreateFile { 673 | filename: id_file.to_string_lossy().to_string(), 674 | }) 675 | .and_then(|file| { 676 | tokio::io::write_all(file, id).context(crate::error::WriteFile) 677 | }) 678 | .map(|_| ()) 679 | } 680 | 681 | fn client_id_file( 682 | auth: crate::protocol::AuthType, 683 | must_exist: bool, 684 | ) -> Option { 685 | let filename = format!("client-oauth-{}", auth.name()); 686 | crate::dirs::Dirs::new().data_file(&filename, must_exist) 687 | } 688 | --------------------------------------------------------------------------------