http3-ytproxy/main.go
Fijxu 319991c7b8
All checks were successful
CI / build (push) Successful in 1m1s
fix(videoplayback): Use HEAD requests to get the location of the videoplayback URL before doing a POST
"RFC 1945 and RFC 2068 specify that the client is not allowed to change
the method on the redirected request. However, most existing user agent
implementations treat 302 as if it were a 303 response, performing a GET
on the Location field-value regardless of the original request method.
The status codes 303 and 307 have been added for servers that wish to
make unambiguously clear which kind of reaction is expected of the
client."

Before doing this, POST requests that got a 302 status code, get
converted automatically to GET requests by the standard, which should
not happen. That is why Invidious does 5 HEAD requests to get the
Location header and send a correct URL on the POST request (NOTE:
INVIDIOUS UPSTREAMS STILL USES GET REQUESTS TO GET THE VIDEO FROM
YOUTUBE, THAT IS SUBJECT TO CHANGE with https://github.com/iv-org/invidious/issues/5034:

164d764d55/src/invidious/routes/video_playback.cr (L48-L78)

Due to this the redirects, the Host header can also change, so if the
stream is open for a long time and it gets redirected to another URL,
the Host header used the old Host header instead of the new one returned
by the Location header on the HEAD request to googlevideo.com, making
the request fail.

I hope this shit works tho
2025-02-19 18:41:49 -03:00

651 lines
18 KiB
Go

package main
import (
"crypto/tls"
"encoding/json"
"errors"
"flag"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/conduitio/bwlimit"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/procfs"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
var (
wl = flag.Int("w", 8000, "Write limit in Kbps")
rl = flag.Int("r", 8000, "Read limit in Kbps")
)
// QUIC doesn't seem to support HTTP nor SOCKS5 proxies due to how it's made.
// (Since it's UDP)
var h3client = &http.Client{
Transport: &http3.Transport{},
Timeout: 10 * time.Second,
}
var dialer = &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
var proxy string
// http/2 client
var h2client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
Dial: func(network, addr string) (net.Conn, error) {
var net string
if ipv6_only {
net = "tcp6"
} else {
net = "tcp4"
}
return dialer.Dial(net, addr)
},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 20 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 30 * time.Second,
ReadBufferSize: 16 * 1024,
ForceAttemptHTTP2: true,
MaxConnsPerHost: 0,
MaxIdleConnsPerHost: 10,
MaxIdleConns: 0,
Proxy: func(r *http.Request) (*url.URL, error) {
if proxy != "" {
return url.Parse(proxy)
}
return nil, nil
},
},
}
var client *http.Client
var default_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
var allowed_hosts = []string{
"youtube.com",
"googlevideo.com",
"ytimg.com",
"ggpht.com",
"googleusercontent.com",
}
var strip_headers = []string{
"Accept-Encoding",
"Authorization",
"Origin",
"Referer",
"Cookie",
"Set-Cookie",
"Etag",
"Alt-Svc",
"Server",
"Cache-Control",
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-to
"report-to",
}
var path_prefix = ""
var manifest_re = regexp.MustCompile(`(?m)URI="([^"]+)"`)
var ipv6_only = false
var version string
var h3s bool
var domain_only_access bool = false
var programInit = time.Now()
type ConnectionWatcher struct {
totalEstablished int64
established int64
active int64
idle int64
}
// https://stackoverflow.com/questions/51317122/how-to-get-number-of-idle-and-active-connections-in-go
// OnStateChange records open connections in response to connection
// state changes. Set net/http Server.ConnState to this method
// as value.
func (cw *ConnectionWatcher) OnStateChange(conn net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
atomic.AddInt64(&stats_.EstablishedConnections, 1)
metrics.EstablishedConnections.Inc()
atomic.AddInt64(&stats_.TotalConnEstablished, 1)
metrics.TotalConnEstablished.Inc()
// case http.StateActive:
// atomic.AddInt64(&cw.active, 1)
case http.StateClosed, http.StateHijacked:
atomic.AddInt64(&stats_.EstablishedConnections, -1)
metrics.EstablishedConnections.Dec()
}
}
// // Count returns the number of connections at the time
// // the call.
// func (cw *ConnectionWatcher) Count() int {
// return int(atomic.LoadInt64(&cw.n))
// }
// // Add adds c to the number of active connections.
// func (cw *ConnectionWatcher) Add(c int64) {
// atomic.AddInt64(&cw.n, c)
// }
var cw ConnectionWatcher
type statusJson struct {
Version string `json:"version"`
Uptime time.Duration `json:"uptime"`
RequestCount int64 `json:"requestCount"`
RequestPerSecond int64 `json:"requestPerSecond"`
RequestPerMinute int64 `json:"requestPerMinute"`
TotalConnEstablished int64 `json:"totalEstablished"`
EstablishedConnections int64 `json:"establishedConnections"`
ActiveConnections int64 `json:"activeConnections"`
IdleConnections int64 `json:"idleConnections"`
RequestsForbiddenPerSec struct {
Videoplayback int64 `json:"videoplayback"`
}
RequestsForbidden struct {
Videoplayback int64 `json:"videoplayback"`
Vi int64 `json:"vi"`
Ggpht int64 `json:"ggpht"`
} `json:"requestsForbidden"`
}
var stats_ = statusJson{
Version: version + "-" + runtime.GOARCH,
Uptime: 0,
RequestCount: 0,
RequestPerSecond: 0,
RequestPerMinute: 0,
TotalConnEstablished: 0,
EstablishedConnections: 0,
ActiveConnections: 0,
IdleConnections: 0,
RequestsForbiddenPerSec: struct {
Videoplayback int64 `json:"videoplayback"`
}{
Videoplayback: 0,
},
RequestsForbidden: struct {
Videoplayback int64 `json:"videoplayback"`
Vi int64 `json:"vi"`
Ggpht int64 `json:"ggpht"`
}{
Videoplayback: 0,
Vi: 0,
Ggpht: 0,
},
}
type Metrics struct {
Uptime prometheus.Gauge
RequestCount prometheus.Counter
RequestPerSecond prometheus.Gauge
RequestPerMinute prometheus.Gauge
TotalConnEstablished prometheus.Counter
EstablishedConnections prometheus.Gauge
ActiveConnections prometheus.Gauge
IdleConnections prometheus.Gauge
RequestForbidden struct {
Videoplayback prometheus.Counter
Vi prometheus.Counter
Ggpht prometheus.Counter
}
}
var metrics = Metrics{
Uptime: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_uptime",
}),
RequestCount: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_request_count",
}),
RequestPerSecond: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_request_per_second",
}),
RequestPerMinute: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_request_per_minute",
}),
TotalConnEstablished: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_total_conn_established",
}),
EstablishedConnections: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_established_conns",
}),
ActiveConnections: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_active_conns",
}),
IdleConnections: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "http3_ytproxy_idle_conns",
}),
RequestForbidden: struct {
Videoplayback prometheus.Counter
Vi prometheus.Counter
Ggpht prometheus.Counter
}{
Videoplayback: prometheus.NewCounter(prometheus.CounterOpts{
Name: "http3_ytproxy_request_forbidden_videoplayback",
}),
Vi: prometheus.NewCounter(prometheus.CounterOpts{
Name: "http3_ytproxy_request_forbidden_vi",
}),
Ggpht: prometheus.NewCounter(prometheus.CounterOpts{
Name: "http3_ytproxy_request_forbidden_ggpht",
}),
},
}
func root(w http.ResponseWriter, req *http.Request) {
const msg = `
HTTP youtube proxy for https://inv.nadeko.net
https://git.nadeko.net/Fijxu/http3-ytproxy
Routes:
/stats
/health`
io.WriteString(w, msg)
}
// CustomHandler wraps the default promhttp.Handler with custom logic
func metricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// To prevent accessing from the bare IP address
if req.Host == "" || net.ParseIP(strings.Split(req.Host, ":")[0]) != nil {
w.WriteHeader(444)
return
}
metrics.Uptime.Set(float64(time.Duration(time.Since(programInit).Seconds())))
promhttp.Handler().ServeHTTP(w, req)
})
}
func stats(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
stats_.Uptime = time.Duration(time.Since(programInit).Seconds())
// stats_.TotalEstablished = int64(cw.totalEstablished)
// stats_.EstablishedConnections = int64(cw.established)
// stats_.ActiveConnections = int64(cw.active)
// stats_.IdleConnections = int64(cw.idle)
if err := json.NewEncoder(w).Encode(stats_); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func health(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
io.WriteString(w, "OK")
}
func requestPerSecond() {
var last int64
for {
time.Sleep(1 * time.Second)
current := stats_.RequestCount
stats_.RequestPerSecond = current - last
metrics.RequestPerSecond.Set(float64(stats_.RequestPerSecond))
last = current
}
}
func requestPerMinute() {
var last int64
for {
time.Sleep(60 * time.Second)
current := stats_.RequestCount
stats_.RequestPerMinute = current - last
metrics.RequestPerMinute.Set(float64(stats_.RequestPerMinute))
last = current
}
}
var tx uint64
func blockCheckerCalc(p *procfs.Proc) {
var last uint64
for {
time.Sleep(1 * time.Second)
// p.NetDev should never fail.
stat, _ := p.NetDev()
current := stat.Total().TxBytes
tx = current - last
last = current
}
}
// Detects if a backend has been blocked based on the amount of bandwidth
// reported by procfs.
// This may be the best way to detect if the IP has been blocked from googlevideo
// servers. I would like to detect blockages using the status code that googlevideo
// returns, which most of the time is 403 (Forbidden). But this error code is not
// exclusive to IP blocks, it's also returned for other reasons like a wrong
// query parameter like `pot` (po_token) or anything like that.
func blockChecker(gh string, cooldown int) {
log.Println("[INFO] Starting blockchecker")
// Sleep for 60 seconds before commencing the loop
time.Sleep(60 * time.Second)
url := "http://" + gh + "/v1/openvpn/status"
p, err := procfs.Self()
if err != nil {
log.Printf("[ERROR] [procfs]: Could not get process: %s\n", err)
log.Println("[INFO] Blockchecker will not run, so if the VPN IP used on gluetun gets blocked, it will not be rotated!")
return
}
go blockCheckerCalc(&p)
for {
time.Sleep(time.Duration(cooldown) * time.Second)
if float64(tx)*0.000008 < 2.0 {
body := "{\"status\":\"stopped\"}\""
// This should never fail too
request, _ := http.NewRequest("PUT", url, strings.NewReader(body))
_, err = client.Do(request)
if err != nil {
log.Printf("[ERROR] Failed to send request to gluetun.")
} else {
log.Printf("[INFO] Request to change IP sent to gluetun successfully")
}
}
}
}
func beforeMisc(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
defer panicHandler(w)
// To prevent accessing from the bare IP address
if domain_only_access && (req.Host == "" || net.ParseIP(strings.Split(req.Host, ":")[0]) != nil) {
w.WriteHeader(444)
return
}
next(w, req)
}
}
func beforeProxy(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
defer panicHandler(w)
// To prevent accessing from the bare IP address
if domain_only_access && (req.Host == "" || net.ParseIP(strings.Split(req.Host, ":")[0]) != nil) {
w.WriteHeader(444)
return
}
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Max-Age", "1728000")
w.Header().Set("Strict-Transport-Security", "max-age=86400")
w.Header().Set("X-Powered-By", "http3-ytproxy "+version+"-"+runtime.GOARCH)
if h3s {
w.Header().Set("Alt-Svc", "h3=\":8443\"; ma=86400")
}
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if req.Method != "GET" && req.Method != "HEAD" {
w.WriteHeader(405)
io.WriteString(w, "Only GET and HEAD requests are allowed.")
return
}
atomic.AddInt64(&stats_.RequestCount, 1)
metrics.RequestCount.Inc()
next(w, req)
}
}
func main() {
defaultHost := "0.0.0.0"
defaultPort := "8080"
defaultSock := "/tmp/http-ytproxy.sock"
defaultTLSCert := "/data/cert.pem"
defaultTLSKey := "/data/key.key"
var https bool = false
var h3c bool = false
var ipv6 bool = false
var bc bool = true
if strings.ToLower(os.Getenv("HTTPS")) == "true" {
https = true
}
if strings.ToLower(os.Getenv("H3C")) == "true" {
h3c = true
}
if strings.ToLower(os.Getenv("H3S")) == "true" {
h3s = true
}
if strings.ToLower(os.Getenv("IPV6_ONLY")) == "true" {
ipv6 = true
}
if strings.ToLower(os.Getenv("BLOCK_CHECKER")) == "false" {
bc = false
}
if strings.ToLower(os.Getenv("DOMAIN_ONLY_ACCESS")) == "true" {
domain_only_access = true
}
tls_cert := os.Getenv("TLS_CERT")
if tls_cert == "" {
tls_cert = defaultTLSCert
}
tls_key := os.Getenv("TLS_KEY")
if tls_key == "" {
tls_key = defaultTLSKey
}
sock := os.Getenv("SOCK_PATH")
if sock == "" {
sock = defaultSock
}
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
host := os.Getenv("HOST")
if host == "" {
host = defaultHost
}
// gh is where the gluetun api is located
gh := os.Getenv("GLUETUN_HOSTNAME")
if gh == "" {
gh = "127.0.0.1:8000"
}
bc_cooldown := os.Getenv("BLOCK_CHECKER_COOLDOWN")
if bc_cooldown == "" {
bc_cooldown = "60"
}
proxy = os.Getenv("PROXY")
flag.BoolVar(&https, "https", https, "Use built-in https server (recommended)")
flag.BoolVar(&h3c, "h3c", h3c, "Use HTTP/3 for client requests (high CPU usage)")
flag.BoolVar(&h3s, "h3s", h3s, "Use HTTP/3 for server requests, (requires HTTPS)")
flag.BoolVar(&ipv6_only, "ipv6_only", ipv6_only, "Only use ipv6 for requests")
flag.StringVar(&tls_cert, "tls-cert", tls_cert, "TLS Certificate path")
flag.StringVar(&tls_key, "tls-key", tls_key, "TLS Certificate Key path")
flag.StringVar(&sock, "s", sock, "Specify a socket name")
flag.StringVar(&port, "p", port, "Specify a port number")
flag.StringVar(&host, "l", host, "Specify a listen address")
flag.Parse()
if h3c {
client = h3client
} else {
client = h2client
}
if https {
if len(tls_cert) <= 0 {
log.Fatal("tls-cert argument is missing, you need a TLS certificate for HTTPS")
}
if len(tls_key) <= 0 {
log.Fatal("tls-key argument is missing, you need a TLS key for HTTPS")
}
}
ipv6_only = ipv6
mux := http.NewServeMux()
// MISC ROUTES
mux.HandleFunc("/", beforeMisc(root))
mux.HandleFunc("/health", beforeMisc(health))
mux.HandleFunc("/stats", beforeMisc(stats))
prometheus.MustRegister(metrics.Uptime)
prometheus.MustRegister(metrics.ActiveConnections)
prometheus.MustRegister(metrics.IdleConnections)
prometheus.MustRegister(metrics.EstablishedConnections)
prometheus.MustRegister(metrics.TotalConnEstablished)
prometheus.MustRegister(metrics.RequestCount)
prometheus.MustRegister(metrics.RequestPerSecond)
prometheus.MustRegister(metrics.RequestPerMinute)
prometheus.MustRegister(metrics.RequestForbidden.Videoplayback)
prometheus.MustRegister(metrics.RequestForbidden.Vi)
prometheus.MustRegister(metrics.RequestForbidden.Ggpht)
mux.Handle("/metrics", metricsHandler())
// PROXY ROUTES
mux.HandleFunc("/videoplayback", beforeProxy(videoplayback))
mux.HandleFunc("/vi/", beforeProxy(vi))
mux.HandleFunc("/vi_webp/", beforeProxy(vi))
mux.HandleFunc("/sb/", beforeProxy(vi))
mux.HandleFunc("/ggpht/", beforeProxy(ggpht))
mux.HandleFunc("/a/", beforeProxy(ggpht))
mux.HandleFunc("/ytc/", beforeProxy(ggpht))
go requestPerSecond()
go requestPerMinute()
if bc {
num, err := strconv.Atoi(bc_cooldown)
if err != nil {
log.Fatalf("[FATAL] Error while setting BLOCK_CHECKER_COOLDOWN: %s", err)
}
go blockChecker(gh, num)
}
ln, err := net.Listen("tcp", host+":"+port)
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
// 1Kbit = 125Bytes
var (
writeLimit = bwlimit.Byte(*wl) * bwlimit.Byte(125)
readLimit = bwlimit.Byte(*rl) * bwlimit.Byte(125)
)
ln = bwlimit.NewListener(ln, writeLimit, readLimit)
// srvDialer := bwlimit.NewDialer(&net.Dialer{}, writeLimit, readLimit)
srv := &http.Server{
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 1 * time.Hour,
ConnState: cw.OnStateChange,
}
srvh3 := &http3.Server{
Handler: mux,
EnableDatagrams: false, // https://quic.video/blog/never-use-datagrams/ (Read it)
IdleTimeout: 120 * time.Second,
TLSConfig: http3.ConfigureTLSConfig(&tls.Config{}),
QUICConfig: &quic.Config{
// KeepAlivePeriod: 10 * time.Second,
MaxIncomingStreams: 256, // I'm not sure if this is correct.
MaxIncomingUniStreams: 256, // Same as above
},
Addr: host + ":" + port,
}
syscall.Unlink(sock)
socket_listener, err := net.Listen("unix", sock)
if err != nil {
log.Println("Failed to bind to UDS, please check the socket name", err.Error())
} else {
defer socket_listener.Close()
// To allow everyone to access the socket
err = os.Chmod(sock, 0777)
if err != nil {
log.Println("Failed to set socket permissions to 777:", err.Error())
return
} else {
log.Println("Setting socket permissions to 777")
}
go srv.Serve(socket_listener)
log.Println("Unix socket listening at:", string(sock))
if https {
if _, err := os.Open(tls_cert); errors.Is(err, os.ErrNotExist) {
log.Panicf("Certificate file does not exist at path '%s'", tls_cert)
}
if _, err := os.Open(tls_key); errors.Is(err, os.ErrNotExist) {
log.Panicf("Key file does not exist at path '%s'", tls_key)
}
log.Println("Serving HTTPS at port", string(port)+"/tcp")
go func() {
if err := srv.ServeTLS(ln, tls_cert, tls_key); err != nil {
log.Fatal("Failed to server HTTP/2", err.Error())
}
}()
if h3s {
log.Println("Serving HTTP/3 (HTTPS) via QUIC at port", string(port)+"/udp")
go func() {
if err := srvh3.ListenAndServeTLS(tls_cert, tls_key); err != nil {
log.Fatal("Failed to serve HTTP/3:", err.Error())
}
}()
}
select {}
} else {
log.Println("Serving HTTP at port", string(port))
if err := srv.Serve(ln); err != nil {
log.Fatal(err)
}
}
}
}