diff --git a/.gitignore b/.gitignore index 2f7896d..f006f91 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target/ +debian/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8d0f2d3..6c0eefa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" +[[package]] +name = "arc-swap" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d78ce20460b82d3fa150275ed9d55e21064fc7951177baacf86a145c4a4b1f" + [[package]] name = "async-compression" version = "0.3.12" @@ -91,6 +97,7 @@ dependencies = [ "bitflags", "bytes", "futures-util", + "headers", "http", "http-body", "hyper", @@ -125,6 +132,26 @@ dependencies = [ "mime", ] +[[package]] +name = "axum-server" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf18303ef7e23b045301555bf8a0dfbc1444ea1a37b3c81757a32680ace4d7d" +dependencies = [ + "arc-swap", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "rustls", + "rustls-pemfile 1.0.0", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "base64" version = "0.13.0" @@ -137,6 +164,15 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "3.3.4" @@ -184,16 +220,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "3.1.8" +version = "3.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c47df61d9e16dc010b55dba1952a57d8c215dbb533fd13cdd13369aac73b1c" +checksum = "6aad2534fad53df1cc12519c5cda696dd3e20e6118a027e24054aea14a0bdcbe" dependencies = [ "atty", "bitflags", "clap_derive", + "clap_lex", "indexmap", "lazy_static", - "os_str_bytes", "strsim", "termcolor", "textwrap", @@ -212,6 +248,15 @@ dependencies = [ "syn", ] +[[package]] +name = "clap_lex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189ddd3b5d32a70b35e7686054371742a937b0d99128e76dde6340210e966669" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "confy" version = "0.4.0" @@ -243,6 +288,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.3.2" @@ -252,6 +306,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories-next" version = "2.0.0" @@ -343,6 +417,17 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.21" @@ -362,9 +447,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", ] [[package]] @@ -403,6 +500,31 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +[[package]] +name = "headers" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cff78e5788be1e0ab65b04d306b2ed5092c815ec97ec70f4ebd5aee158aa55d" +dependencies = [ + "base64", + "bitflags", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha-1", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.0" @@ -518,9 +640,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e70ee094dc02fd9c13fdad4940090f22dbd6ac7c9e7094a46cf0232a50bc7c" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" [[package]] name = "isocountry" @@ -575,6 +697,7 @@ dependencies = [ "ansi_term", "anyhow", "axum", + "axum-server", "clap", "confy", "const_format", @@ -588,6 +711,7 @@ dependencies = [ "serde_json", "tokio", "tower", + "tower-default-headers", "tower-http", "tracing", "tracing-subscriber", @@ -681,9 +805,6 @@ name = "os_str_bytes" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" -dependencies = [ - "memchr", -] [[package]] name = "percent-encoding" @@ -846,7 +967,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls", - "rustls-pemfile", + "rustls-pemfile 0.3.0", "serde", "serde_json", "serde_urlencoded", @@ -898,6 +1019,15 @@ dependencies = [ "base64", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7522c9de787ff061458fe9a829dc790a3f5b22dc571694fc5883f448b94d9a9" +dependencies = [ + "base64", +] + [[package]] name = "ryu" version = "1.0.9" @@ -966,6 +1096,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.4" @@ -1166,9 +1307,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" dependencies = [ "serde", ] @@ -1190,12 +1331,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-default-headers" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c4f3bfa5d42bef9c97add96bafba4d765aec40b1173179d6ab5e315ccc66b9" +dependencies = [ + "futures-util", + "http", + "pin-project", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aba3f3efabf7fb41fae8534fc20a817013dd1c12cb45441efb6c82e6556b4cd8" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -1204,6 +1359,8 @@ dependencies = [ "http-body", "http-range-header", "pin-project-lite", + "tokio", + "tokio-util 0.7.1", "tower", "tower-layer", "tower-service", @@ -1223,9 +1380,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80b9fa4360528139bc96100c160b7ae879f5567f49f1782b0b02035b0358ebf3" +checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09" dependencies = [ "cfg-if", "log", @@ -1247,9 +1404,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dfce9f3241b150f36e8e54bb561a742d5daa1a47b5dd9a5ce369fd4a4db2210" +checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f" dependencies = [ "lazy_static", "valuable", @@ -1286,6 +1443,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "unicode-bidi" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index 22ad1b9..b26f3a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,23 +9,27 @@ description = "Avoid Twitch ads by grabbing video playlists from Russia" [dependencies] anyhow = "1.0.43" -clap = { version = "3.0", features = ["derive"] } +clap = { version = "3.1.9", features = ["derive"] } once_cell = "1.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.69" -serde-tuple-vec-map = "1.0" rand = "0.8.4" const_format = "0.2.18" url = "2.2.2" -uuid = { version = "0.8.2", features = ["v4", "serde"] } extend = "1.1.2" -confy = { git = "https://github.com/rust-cli/confy.git" } -isocountry = "0.3.2" +# Only used by Hola code: +uuid = { version = "0.8.2", features = ["v4", "serde"], optional = true } +confy = { git = "https://github.com/rust-cli/confy.git", optional = true } +isocountry = { version = "0.3.2", optional = true } +serde-tuple-vec-map = { version = "1.0", optional = true } -axum = "0.5" +axum = { version = "0.5", features = ["headers"] } +axum-server = { version = "0.4", optional = true } tokio = { version = "1.13", features = ["macros", "rt-multi-thread"] } tower = { version = "0.4.10", features = ["util"] } tower-http = { version = "0.2", features = ["cors"] } +tower-default-headers = "0.1.1" + tracing = { version = "0.1", features = ["release_max_level_debug"] } # disable trace in releases tracing-subscriber = "0.3" @@ -37,6 +41,17 @@ features = ["rustls-tls", "gzip", "brotli", "socks", "json"] [target.'cfg(windows)'.dependencies] ansi_term = "0.12" +[features] +default = ["hola"] +hola = ["confy", "isocountry", "serde-tuple-vec-map", "uuid"] +gzip = ["tower-http/compression-gzip"] +tls = ["axum-server/tls-rustls"] + [profile.release] codegen-units = 1 -lto = true \ No newline at end of file +lto = true + +# for `cargo deb` +[package.metadata.deb] +default-features = false +features = ["gzip", "tls"] diff --git a/src/hello.rs b/src/hello.rs index 1a03654..ad2c00e 100644 --- a/src/hello.rs +++ b/src/hello.rs @@ -64,7 +64,7 @@ pub(crate) struct BgInitResponse { } static CLIENT: Lazy = - Lazy::new(|| ClientBuilder::new().user_agent(crate::common::USER_AGENT).build().unwrap()); + Lazy::new(|| ClientBuilder::new().user_agent(common::USER_AGENT).build().unwrap()); const VPN_COUNTRIES_URL: &str = concatcp!(CCGI_URL, "vpn_countries.json"); diff --git a/src/hello_config.rs b/src/hello_config.rs new file mode 100644 index 0000000..f453157 --- /dev/null +++ b/src/hello_config.rs @@ -0,0 +1,49 @@ +//! Stores some of the Hola code to make conditional compilation cleaner. I should probably +//! move more code into this file. + +use anyhow::Result; +use rand::prelude::SliceRandom; +use reqwest::{ClientBuilder, Proxy}; +use serde::{Deserialize, Serialize}; +use tracing::debug; +use uuid::Uuid; + +use hello::ProxyType; + +use crate::{common, hello, Opts}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct Config { + uuid: Option, +} + +/// Connect to Hola, retrieve tunnels, set the ClientBuilder to use one of the proxies. Updates +/// stored UUID in the config if we regenerated our creds. +pub(crate) async fn setup_hola( + config: &mut Config, + opts: &Opts, + cb: ClientBuilder, +) -> Result { + let uuid = if !opts.regen_creds { config.uuid } else { None }; + let (bg, uuid) = hello::background_init(uuid).await?; + config.uuid = Some(uuid); + if bg.blocked || bg.permanent { + panic!("Blocked by Hola: {:?}", bg); + } + let proxy_type = ProxyType::Direct; + let tunnels = hello::get_tunnels(&uuid, bg.key, &opts.country, proxy_type, 3).await?; + debug!("{:?}", tunnels); + let login = hello::uuid_to_login(&uuid); + let password = tunnels.agent_key; + debug!("login: {}", login); + debug!("password: {}", password); + let (hostname, ip) = + tunnels.ip_list.choose(&mut common::get_rng()).expect("no tunnels found in hola response"); + let port = proxy_type.get_port(&tunnels.port); + let proxy = if !hostname.is_empty() { + format!("https://{}:{}", hostname, port) + } else { + format!("http://{}:{}", ip, port) + }; // does this check actually need to exist? + Ok(cb.proxy(Proxy::all(proxy)?.basic_auth(&login, &password))) +} diff --git a/src/main.rs b/src/main.rs index df917c0..c9e92fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,94 +1,114 @@ use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; +#[cfg(feature = "tls")] +use std::path::PathBuf; use std::time::Duration; use anyhow::Result; use axum::{ body::BoxBody, extract::{Path, Query}, - http::{Response, StatusCode}, + headers::UserAgent, + http::{ + header::{CACHE_CONTROL, USER_AGENT}, + HeaderMap, HeaderValue, Response, StatusCode, + }, response::IntoResponse, routing::get, - Json, Router, + Json, Router, TypedHeader, }; use clap::Parser; use extend::ext; use once_cell::sync::OnceCell; -use rand::{distributions::Alphanumeric, seq::SliceRandom, Rng}; +use rand::{distributions::Alphanumeric, Rng}; use reqwest::{Client, ClientBuilder, Proxy}; use serde::{Deserialize, Serialize}; use serde_json::json; +use tower_default_headers::DefaultHeadersLayer; use tower_http::cors::{Any, CorsLayer}; #[allow(unused)] use tracing::{debug, error, info, warn, Level}; use url::Url; -use uuid::Uuid; - -use crate::hello::ProxyType; mod common; +#[cfg(feature = "hola")] mod hello; +#[cfg(feature = "hola")] +mod hello_config; /// Client-ID of Twitch's web player. Shown in the clear if you load the main page. -/// Try `curl -s https://www.twitch.tv | tidy -q | grep '"Client-ID":"'`. +/// Try `curl -s https://www.twitch.tv | tidy -q | grep 'clientId='`. const TWITCH_CLIENT: &str = "kimne78kx3ncx6brgo4mv6wki5h1ko"; const ID_PARAM: &str = "id"; const VOD_ENDPOINT: &str = const_format::concatcp!("/vod/:", ID_PARAM); const LIVE_ENDPOINT: &str = const_format::concatcp!("/live/:", ID_PARAM); +// for Firefox only +const STATUS_ENDPOINT: &str = "/stat/"; static CLIENT: OnceCell = OnceCell::new(); #[derive(Parser, Debug)] #[clap(version, about)] -struct Opts { +pub(crate) struct Opts { /// Address for this server to listen on. #[clap(short, long, default_value = "127.0.0.1")] address: IpAddr, /// Port for this server to listen on. #[clap(short, long, default_value = "9595")] server_port: u16, - /// Connect directly to Twitch, without a proxy. Potentially useful when running this - /// server remotely. - #[clap(long, conflicts_with_all(&["proxy", "country"]))] + /// Connect directly to Twitch, without a proxy. Useful when running this server remotely + /// in a country where Twitch doesn't serve ads. + #[cfg_attr(feature = "hola", clap(long, conflicts_with_all(&["proxy", "country"])))] + #[cfg_attr(not(feature = "hola"), clap(long, conflicts_with_all(&["proxy"])))] no_proxy: bool, /// Custom proxy to use, instead of Hola. Takes the form of 'scheme://host:port', /// where scheme is one of: http/https/socks5/socks5h. /// Must be in a country where Twitch doesn't serve ads for this system to work. - #[clap(short, long)] - proxy: Option, + #[cfg_attr(feature = "hola", clap(short, long))] + #[cfg_attr(not(feature = "hola"), clap(short, long, required_unless_present = "no-proxy"))] + proxy: Option, /// Country to request a proxy in. See https://client.hola.org/client_cgi/vpn_countries.json. + #[cfg(feature = "hola")] #[clap(short, long, conflicts_with = "proxy", parse(try_from_str = parse_country), default_value = "ru")] country: String, /// Don't save Hola credentials. + #[cfg(feature = "hola")] #[clap(short, long, conflicts_with = "proxy")] discard_creds: bool, /// Regenerate Hola credentials (don't load them). + #[cfg(feature = "hola")] #[clap(short, long, conflicts_with = "proxy")] regen_creds: bool, /// List Hola's available countries, for use with --country + #[cfg(feature = "hola")] #[clap(long)] list_countries: bool, + /// Private key for TLS. Enables TLS if specified. + #[cfg(feature = "tls")] + #[clap(long, requires = "tls-cert", display_order = 4800)] + tls_key: Option, + /// Server certificate for TLS. + #[cfg(feature = "tls")] + #[clap(long, display_order = 4801)] + tls_cert: Option, /// Debug logging. - #[clap(long)] + #[clap(long, display_order = 5000)] debug: bool, } -fn parse_country(input: &str) -> anyhow::Result { +#[cfg(feature = "hola")] +fn parse_country(input: &str) -> Result { if input.len() != 2 { anyhow::bail!("Country argument invalid, must be 2 letters: {}", input); } // better to actually validate from the API, too lazy Ok(input.to_ascii_lowercase()) } -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -struct Config { - uuid: Option, -} - +#[cfg(feature = "hola")] const CRATE_NAME: &str = env!("CARGO_PKG_NAME"); #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() -> Result<()> { let opts = Opts::parse(); #[cfg(windows)] if let Err(code) = ansi_term::enable_ansi_support() { @@ -97,10 +117,12 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_max_level(if opts.debug { Level::DEBUG } else { Level::INFO }) .init(); + #[cfg(feature = "hola")] if opts.list_countries { return hello::list_countries().await; } - let mut config: Config = confy::load(CRATE_NAME, None)?; + #[cfg(feature = "hola")] + let mut config: hello_config::Config = confy::load(CRATE_NAME, None)?; // TODO: SOCKS4 for reqwest let mut cb = ClientBuilder::new().user_agent(common::USER_AGENT).timeout(Duration::from_secs(20)); @@ -109,80 +131,108 @@ async fn main() -> anyhow::Result<()> { } else if opts.no_proxy { cb = cb.no_proxy() } else { - cb = setup_hola(&mut config, &opts, cb).await?; - if !opts.discard_creds { - info!( - "Saving Hola credentials to {}", - confy::get_configuration_file_path(CRATE_NAME, None)?.display() - ); - confy::store(CRATE_NAME, None, &config)?; + #[cfg(feature = "hola")] + { + cb = hello_config::setup_hola(&mut config, &opts, cb).await?; + if !opts.discard_creds { + info!( + "Saving Hola credentials to {}", + confy::get_configuration_file_path(CRATE_NAME, None)?.display() + ); + confy::store(CRATE_NAME, None, &config)?; + } } }; let client = cb.build()?; CLIENT.set(client).unwrap(); - let app = Router::new() + + let mut default_headers = HeaderMap::with_capacity(1); + default_headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache,no-store")); + + let mut router = Router::new() .route(VOD_ENDPOINT, get(process_vod)) .route(LIVE_ENDPOINT, get(process_live)) - .layer(CorsLayer::new().allow_origin(Any)); + .route(STATUS_ENDPOINT, get(status)); + #[cfg(feature = "gzip")] + { + router = router.layer(tower_http::compression::CompressionLayer::new()); + } + router = router + .layer(CorsLayer::new().allow_origin(Any)) + .layer(DefaultHeadersLayer::new(default_headers)); let addr = SocketAddr::from((opts.address, opts.server_port)); - axum::Server::bind(&addr).serve(app.into_make_service()).await?; + #[cfg(feature = "tls")] + if let (Some(key), Some(cert)) = (opts.tls_key, opts.tls_cert) { + let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert, key).await?; + return Ok(axum_server::bind_rustls(addr, config) + .serve(router.into_make_service()) + .await?); + } + axum::Server::bind(&addr).serve(router.into_make_service()).await?; Ok(()) } -/// Connect to Hola, retrieve tunnels, set the ClientBuilder to use one of the proxies. Updates -/// stored UUID in the config if we regenerated our creds. -async fn setup_hola( - config: &mut Config, - opts: &Opts, - cb: ClientBuilder, -) -> anyhow::Result { - let uuid = if !opts.regen_creds { config.uuid } else { None }; - let (bg, uuid) = hello::background_init(uuid).await?; - config.uuid = Some(uuid); - if bg.blocked || bg.permanent { - panic!("Blocked by Hola: {:?}", bg); - } - let proxy_type = ProxyType::Direct; - let tunnels = hello::get_tunnels(&uuid, bg.key, &opts.country, proxy_type, 3).await?; - debug!("{:?}", tunnels); - let login = hello::uuid_to_login(&uuid); - let password = tunnels.agent_key; - debug!("login: {}", login); - debug!("password: {}", password); - let (hostname, ip) = - tunnels.ip_list.choose(&mut common::get_rng()).expect("no tunnels found in hola response"); - let port = proxy_type.get_port(&tunnels.port); - let proxy = if !hostname.is_empty() { - format!("https://{}:{}", hostname, port) - } else { - format!("http://{}:{}", ip, port) - }; // does this check actually need to exist? - Ok(cb.proxy(Proxy::all(proxy)?.basic_auth(&login, &password))) +#[derive(Copy, Clone, Debug, Serialize)] +struct Status { + online: bool, +} + +async fn status() -> Json { + // in Chrome-like browsers the extension can download the M3U8, and if that succeeds redirect + // to it in Base64 form. In Firefox that isn't permitted. Checking if the server is online before + // redirecting to it reduces the chance of the extension breaking Twitch. + // TODO: If the server is up but its functionality is broken (proxy rejections etc) this should + // give `online: false` + Json(Status { online: true }) +} + +struct ProcessData { + sid: StreamID, + query: HashMap, + user_agent: UserAgent, } +// the User-Agent header is copied from the user if present +// when using this locally it's basically pointless, but for a remote server handling many users +// it should make it less detectable on Twitch's end (it'll look like more like a VPN endpoint or +// similar rather than an automated system) +// UAs shouldn't be individually identifiable in any remotely normal browser + type QueryMap = Query>; -async fn process_live(Path(id): Path, Query(query): QueryMap) -> Response { - let sid = StreamID::Live(id.to_lowercase()); - process(sid, query).await.into_response() +async fn process_live( + Path(id): Path, + Query(query): QueryMap, + user_agent: Option>, +) -> Response { + let pd = ProcessData { + sid: StreamID::Live(id.into_ascii_lowercase()), + query, + user_agent: user_agent.unwrap_or_common(), + }; + process(pd).await.into_response() } -async fn process_vod(Path(id): Path, Query(query): QueryMap) -> Response { - let sid = StreamID::VOD(id.to_string()); - process(sid, query).await.into_response() +async fn process_vod( + Path(id): Path, + Query(query): QueryMap, + user_agent: Option>, +) -> Response { + let pd = ProcessData { + sid: StreamID::VOD(id.to_string()), + query, + user_agent: user_agent.unwrap_or_common(), + }; + process(pd).await.into_response() } -async fn process(sid: StreamID, query: HashMap) -> AppResult> { - let token = get_token(&sid).await?; - let m3u8 = get_m3u8(&sid.get_url(), token.data.playback_access_token, query).await?; +async fn process(pd: ProcessData) -> AppResult> { + let token = get_token(&pd).await?; + let m3u8 = get_m3u8(&pd, token.data.playback_access_token).await?; Ok(([("Content-Type", "application/vnd.apple.mpegurl")], m3u8).into_response()) } -async fn get_m3u8( - url: &str, - token: PlaybackAccessToken, - query: HashMap, -) -> Result { +async fn get_m3u8(pd: &ProcessData, token: PlaybackAccessToken) -> Result { const PERMITTED_INCOMING_KEYS: [&str; 9] = [ "player_backend", // mediaplayer "playlist_include_framerate", // true @@ -194,18 +244,27 @@ async fn get_m3u8( "allow_source", // true "warp", // true; I have no idea what this is; no longer present ]; - let mut url = Url::parse(url)?; + let mut url = Url::parse(&pd.sid.get_url())?; // set query string automatically using non-identifying parameters - url.query_pairs_mut() - .extend_pairs(query.iter().filter(|(k, _)| PERMITTED_INCOMING_KEYS.contains(&k.as_ref()))); + url.query_pairs_mut().extend_pairs( + pd.query.iter().filter(|(k, _)| PERMITTED_INCOMING_KEYS.contains(&k.as_ref())), + ); // add our fake ID url.query_pairs_mut() .append_pair("p", &common::get_rng().gen_range(0..=9_999_999).to_string()) .append_pair("play_session_id", &generate_id().into_ascii_lowercase()) .append_pair("token", &token.value) .append_pair("sig", &token.signature); - let m3u = - CLIENT.get().unwrap().get(url.as_str()).send().await?.error_for_status()?.text().await?; + let m3u = CLIENT + .get() + .unwrap() + .get(url.as_str()) + .header(USER_AGENT, pd.user_agent.as_str()) + .send() + .await? + .error_for_status()? + .text() + .await?; const UC_START: &str = "USER-COUNTRY=\""; if let Some(country) = m3u.lines().find_map(|line| line.substring_between(UC_START, "\"")) { @@ -216,7 +275,8 @@ async fn get_m3u8( } /// Get an access token for the given stream. -async fn get_token(sid: &StreamID) -> Result { +async fn get_token(pd: &ProcessData) -> Result { + let sid = &pd.sid; let request = json!({ "operationName": "PlaybackAccessToken", "extensions": { @@ -234,12 +294,14 @@ async fn get_token(sid: &StreamID) -> Result { }, }); // XXX: I've seen a different method of doing this that involves X-Device-Id (frontpage only?) + // 2022-04-16: No longer seeing it Ok(CLIENT .get() .unwrap() .post("https://gql.twitch.tv/gql") .header("Client-ID", TWITCH_CLIENT) .header("Device-ID", &generate_id()) + .header(USER_AGENT, pd.user_agent.as_str()) .json(&request) .send() .await? @@ -304,6 +366,14 @@ impl str { } } +#[ext] +impl Option> { + /// Returns the header value or the common User-Agent if not present. + fn unwrap_or_common(self) -> UserAgent { + self.map(|ua| ua.0).unwrap_or_else(|| UserAgent::from_static(common::USER_AGENT)) + } +} + /// Generate an ID suitable for use both as a Device-ID and a play_session_id. /// The latter must be lowercased, as this function returns a mixed-case string. fn generate_id() -> String { @@ -343,11 +413,10 @@ pub(crate) enum StreamID { impl StreamID { pub(crate) fn get_url(&self) -> String { const BASE: &str = "https://usher.ttvnw.net/"; - let endpoint = match &self { - Self::Live(channel) => format!("api/channel/hls/{}.m3u8", channel), - Self::VOD(id) => format!("vod/{}.m3u8", id), - }; - format!("{}{}", BASE, endpoint) + match &self { + Self::Live(channel) => format!("{}api/channel/hls/{}.m3u8", BASE, channel), + Self::VOD(id) => format!("{}vod/{}.m3u8", BASE, id), + } } pub(crate) fn data(&self) -> &str { match self {