lepinkainen / pyfibot

Pyfibot the Python IRC bot
BSD 3-Clause "New" or "Revised" License
51 stars 31 forks source link

Ylilauta.org titles #430

Closed resilar closed 4 years ago

resilar commented 4 years ago

Taitaa merkittävä osa pyfibotin käyttäjistä olla suomalaisia, joten Ylilaudan URLit olisi ihan kiva. Ylilauta on kuitenkin tehnyt titlejen hakemisesta mahdollisimman vaikeeta käsittääkseni bottien kitkemiseen perffisyistä. Selvitin mistä hulavanteista on tarkalleen hypittävä, että Ylilauta suostuu antamaan titlen.

  1. Esivaatimuksena TLS-kättelyssä ALPN:llä HTTP/2-protokollan neuvotteleva HTTP-kirjasto. Väärä HTTP-versio tai ALPN puuttuu ⇒ Ylilaudan palvelin katkaisee yhteyden TLS-kättelyn jälkeen.

  2. Ensimmäinen lataus on antaa "Klikkaa nappia niin tiedämme ettet ole botti" sivun, jonka nappi asettaa key-keksin. Keksin arvon voi poimia sivun sisällöstä triviaalisti vaikkapa regexeillä:

    % curl -s --alpn --http2 https://ylilauta.org/arkisto/122796229 | sed -n 's/^\s*\(.*key=.*\)$/\1/p'
    document.cookie = "key=d68511400e7f3cf631bdf1a1043fcefe;path=/";
  3. Keksi key headereihin ja Ylilauta antaa ystävällisesti titlen.

    $ curl -s --alpn --http2 https://ylilauta.org/arkisto/122796229 -H 'Cookie: key=d68511400e7f3cf631bdf1a1043fcefe' | grep -o '<title>.*<\/title>'
    <title>Tässä langassa pukeudutaan kauppakasseihin - Arkisto | Ylilauta</title>

Pyfibotin kannalta haastavinta on HTTP/2-kutsujen lähettäminen ja HTTP-version sopiminen ALPN:llä. Nopealla vilkaisulla Python-webbikirjastojen HTTP/2 ja ALPN -tuki on olematonta. WONTFIX on varsin perusteltua enkä väitä vastaan, mutta ylemmän curl ratkaisun dokumentoinnista tänne voi olla jollekin hyötyä (issue tracker ei ehkä paras paikka, mutta en näin neljältä aamuyöstä keksinyt parempaakaan). Seuraavassa kommentissa ei-mergekelpoinen purkkaratkaisu pyfibotille, jos joku muukin tarvitsee Ylilauta-titlejä yhtä epätoivoisesti kuin minä.

@Sopsy vois kans yksinkertaisen APIn tarjoamista titlejen hakuun (miksei muuhunkin) ellei se ole liian vaikeeta. Vaikka sitten POST-requilla toimivan, niin ei pitäisi bottienkaan crawlailla niin helposti kuin REST-genkistä GET APIa. Jos joku API on jo olemassa, niin sivistäkää minua.

resilar commented 4 years ago

Rustilla ja Hyperillä toteutettu h2bridge joka siltaa osoitteeseen http://127.0.0.1:13337/ HTTP-lähetetyt kutsut HTTP/2 + ALPN yhteyden läpi komentoriviargumentin argv[0] osoittamalle kohdepalvelimelle (eli tavallisesti ylilauta.org). Ylilauta-spesifinä logiikkana kohdepalvelimen vastauksista kaivetaan key-keksi (jos Ylilauta tarjoaa bottitarkistusta) uudelleenlähetystä varten. Viimeisintä key-arvoa käytetään niin kauan kunnes Ylilauta ohjaa taas bottitarkistussivulle.

Koodi toimii, mutta ei ole nättiä. Hyper teki tästä yllättävän vaikeaa jättämällä Clone/Copy traitit toteuttamatta Request -tyypille joka syödään sen lähetyksessä. Muuten ei olisi ollut mitään ongelmaa, mutta bottitarkistuksen tapauksessa Request tarvitsee lähettää kahdesti. Paremmassa toteutuksessa h2bridge olisi geneerinen HTTP/2+ALPN silta ja Ylilautaan liittyvä logiikka olisi jätetty Python-koodille, mutta en ollut tarpeeksi fiksu toteuttamaan tätä heti oikein ensimmäisellä yrityksellä.

Pyfibottiin tätä siltaviritelmää ei ole järkeä mergetä koska kääntäminen ja konffaaminen on kärsimystä Jos Ylilauta-titlet ovat joillekin välttämättömiä, niin h2bridge toimii väliaikaisratkaisuna toistaiseksi. Paras kuitenkin olisi ettei kenenkään tarvitsisi tätä käyttää ja löytyisi siistimpi ratkaisu jonka pystyisi mergeämään pyfibottiin. Pythonilla HTTP/2+ALPN ovat käsittääkseni aika suuria kysymysmerkkejä ainakin vielä, joten en pidättäisi henkeäni.

h2bridge/Cargo.toml

[package]
name = "h2bridge"
version = "0.1.0"
authors = ["def <def.github-spam@huumeet.info>"]
edition = "2018"

[dependencies]
futures = "0.3"
hyper = "0.13"
hyper-alpn = "0.2"
hyper-tls = "0.4"
lazy_static = "1.4.0"
regex = "1.3"
tokio = { version = "0.2", features = ["full"] }

h2bridge/src/main.rs

use std::pin::Pin;
use std::sync::Mutex;

#[macro_use]
extern crate lazy_static;
use regex::bytes::Regex;
use futures::future::Future;

use hyper::header::{COOKIE, HOST, LOCATION, HeaderValue};
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Method, Request, Response, Server};

static H2BRIDGE_ADDR: &'static str = "127.0.0.1:13337";

type ProxyConnector = hyper_alpn::AlpnConnector;
type ProxyClient = hyper::Client<ProxyConnector>;

fn proxy(mut req: Request<Body>, client: ProxyClient, target: String)
-> Pin<Box<dyn Future<Output=Result<Response<Body>, hyper::Error>> + Send>> {
    lazy_static! {
        static ref KEY: Mutex<String> = Mutex::new(String::new());
        static ref RX_KEY: Regex = Regex::new(r"(key=[0-9a-f]{32});").unwrap();
    }

    // Redirect request to the target
    hyper::http::uri::Builder::new()
        .scheme(req.uri().scheme().map_or("https", |s| s.as_str()))
        .authority(target.as_str())
        .path_and_query(req.uri().path_and_query().map_or("/", |p| p.as_str()))
        .build().ok().and_then(|uri| Some(*req.uri_mut() = uri));
    HeaderValue::from_str(&target).ok()
        .and_then(|value| req.headers_mut().insert(HOST, value));

    // Add key cookie to headers
    KEY.lock().ok().filter(|key| !key.is_empty())
        .and_then(|ref mut key| HeaderValue::from_str(key).ok())
        .and_then(|key| req.headers_mut().insert(COOKIE, key));

    // Parse responses from GET & HEAD requests only
    if !matches!(*req.method(), Method::GET | Method::HEAD) {
        return Box::pin(async move { client.request(req).await });
    }

    Box::pin(async move { 
        // Build a copy of req (Request: !Copy + !Clone, ffs)
        let (parts, body) = req.into_parts();
        let req = Request::get(parts.uri.clone())
            .method(parts.method.clone())
            .version(parts.version);
        let req = match req.body(Body::empty()) {
            Ok(mut req) => {
                // Copy headers (including key cookie)
                parts.headers.iter().for_each(|(key, value)| {
                    req.headers_mut().insert(key, value.clone());
                    //eprintln!("{:?}: {:?}", key, value);
                });
                req
            },
            Err(e) => {
                eprintln!("Error building a request to {:?}: {:?}", target, e);
                return client.request(Request::from_parts(parts, body)).await;
            }
        };

        // Finally, send the copied request (consuming it)
        let res = client.request(req).await?;
        let (mut res_parts, res_body) = res.into_parts();
        let buf = hyper::body::to_bytes(res_body).await?;

        // Update response Location (if HTTP redirecting)
        if let Some(uri) = res_parts.headers.get(LOCATION)
                                            .and_then(|u| u.to_str().ok())  {
            let new_uri = uri.replace(&target, H2BRIDGE_ADDR)
                             .replace("https://", "http://");
            if let Ok(value) = HeaderValue::from_str(&new_uri) {
                res_parts.headers.insert(LOCATION, value);
            }
        }

        // Update key if received a new one
        let mut retry = false;
        for cap in RX_KEY.captures_iter(&buf) {
            if let Some(value) = KEY.lock().ok().and_then(|mut prev_key| {
                if &cap[1] != prev_key.as_bytes() {
                    let key = String::from_utf8_lossy(&cap[1]);
                    *prev_key = key.to_string();
                    Some(key)
                } else {
                    None
                }
            }) {
                println!("New cookie: {}", value);
                retry = true;
                break;
            }
        }

        // Resend if the key cookie changed
        if retry {
            let req = Request::from_parts(parts, body);
            return proxy(req, client, target).await;
        }

        // Return last response
        let res = Response::from_parts(res_parts, Body::from(buf));
        async move { Ok(res) }.await
    })
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let args = std::env::args();
    if args.len() != 2 {
        let arg0 = std::env::args().next().unwrap_or("h2bridge".into());
        eprintln!("Usage: {} [TARGET_DOMAIN]\n", arg0);
        eprintln!(" e.g.: {} ylilauta.org", arg0);
        return Ok(());
    }

    let target = args.last().unwrap();
    let addr: std::net::SocketAddr = H2BRIDGE_ADDR.parse().unwrap();
    println!("Listening @ {:?} (HTTP/2 bridge to '{:?}')", addr, target);

    let client = hyper::Client::builder()
        .http2_only(true)
        .build(ProxyConnector::new());

    let make_svc = make_service_fn(move |_socket: _| {
        let target = target.clone();
        let client = client.clone();
        async move {
            Ok::<_, hyper::Error>(service_fn(move |req: _| {
                proxy(req, client.clone(), target.clone())
            }))
        }
    });

    let server = Server::bind(&addr).serve(make_svc);
    let server = server.with_graceful_shutdown(async {
        tokio::signal::ctrl_c().await
            .expect("failed to install CTRL+C signal handler");
    });

    if let Err(err) = server.await {
        eprintln!("Server error: {}", err);
    }

    Ok(())
}

h2bridge.service (systemd unit)

[Unit]
Description=Ylilauta.org HTTP/2 bridge
AssertPathExists=%h/h2bridge/target/release

[Service]
Type=exec
ExecStart=%h/h2bridge/target/release/h2bridge ylilauta.org

[Install]
WantedBy=multi-user.target
EOF

pyfibot/modules/module_urltitle.py.diff

diff --git a/pyfibot/modules/module_urltitle.py b/pyfibot/modules/module_urltitle.py
index 1a9152d..3b8eaa2 100644
--- a/pyfibot/modules/module_urltitle.py
+++ b/pyfibot/modules/module_urltitle.py
@@ -1613,0 +1614,9 @@ def _handle_gfycat(url):
+def _handle_ylilauta(url):
+    """http*://*ylilauta.org*"""
+    old = url
+    url = url.replace("https://", "http://")
+    url = url.replace("ylilauta.org", "127.0.0.1:13337")
+    log.info("Mapping Ylilauta.org URL to h2bridge: %s -> %s", old, url)
+    return __get_title_tag(url)
+
+
resilar commented 4 years ago

Python HTTP-kirjastojen HTTP/2-tuki on keskimäärin heikkoa eikä ALPN ole itsestäänselvyys. Nykyisillä pyfibotin riippuvuuksilla ei taida onnistua (ALPN kyllä määrittämällä HTTP-yhteydelle soketin tai SSLContextin, mutta HTTP/2 puuttuu). Pari vaihtoehtoa löytyy uusilla riippuvuuksilla.

pycurl on ilmeisin vaihtoehto koska curl komentorivillä taipuu tähän (kuten esimerkissä) ja kaiken pitäisi toimia setopt(CURL_HTTP_VERSION_2TLS) jälkeen (ALPN on option CURLOPT_SSL_ENABLE_ALPN takana, mutta oletuksena päällä). pycurl ei kai sisällä omaa libcurlia, joten lisävaatimuksena on HTTP/2-tuen (ja ALPN-tuen) sisältävä libcurl >=7.47.0 (julkaistu 2016 tammikuussa); esimerkiksi Debian Jessie (8.11) sisältää liian vanhan version.

hyper oli mennyt minulta aikaisemmin ohi, mutta näyttäisi tukevan HTTP/2 sekä ALPN on oletuksena käytössä HTTPS-yhteyksille. Python >=2.7.9 (tai >=3.4) vaaditaan, eli Debian Jessielläkin pitäisi toimia. Kieltämättä hyper näyttäisi lupaavalta ratkaisulta ja Ylilauta-titleille tuki pyfibottiin ei ehkä vaadikaan liikoja. hyper riippuvuus ja tusina riviä key-keksin pyörittelyyn varmaan riittäisi. Ei paha.

resilar commented 4 years ago

Jos hyper riippuvuus on OK, niin lähetän PR:n joskus paremmalla ajalla ellei joku muu innostu ensin.

lepinkainen commented 4 years ago

Hyper tuntuu olevan vähän yliherkkä sisääntulevan datan validoinnin kanssa:

from hyper.contrib import HTTP20Adapter
import requests
s = requests.Session()
s.stream = True
s.mount("https://ylilauta.org/", HTTP20Adapter())
s.headers.update(
    {"User-Agent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:49.0) Gecko/20100101 Firefox/49.0"})
s.headers.update({"Accept-Language": "*"})
s.headers.update(
    {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})

response = s.get("https://ylilauta.org/arkisto/122796229")
print(response)

Tämä leviää näin: h2.exceptions.ProtocolError: Received header value surrounded by whitespace, koska connect-src -headerissa on joko tarkoituksella tai vahingossa whitespacea Hyperin mielestä väärä määrä.

Ja tämän takia missään vaiheessa ei edes päästä kaivelemaan palautuvaa Githubissues.

  • Githubissues is a development platform for aggregating issues.