Compare commits

..

9 commits

Author SHA1 Message Date
dfd65d6046
meow
Some checks failed
CI / build (push) Has been cancelled
2025-02-19 16:46:16 -03:00
a03d265259
test: h2client_2_2
All checks were successful
CI / build (push) Successful in 48s
2025-02-17 02:22:53 -03:00
248d076f47
test: h2client_2
All checks were successful
CI / build (push) Successful in 53s
2025-02-17 02:18:09 -03:00
b9f02ddbea
test: h2client custom dialer for testing
All checks were successful
CI / build (push) Successful in 59s
2025-02-17 02:01:25 -03:00
273067973f
fuck fuck: change block checker cooldown back to 60 seconds
All checks were successful
CI / build (push) Successful in 47s
2025-02-13 02:45:14 -03:00
60c481c9a7
fuck
All checks were successful
CI / build (push) Successful in 51s
2025-02-13 02:37:31 -03:00
70232f56bf
this is supposed to execute every second to be able to calulate the difference of the transmitted bytes
All checks were successful
CI / build (push) Successful in 51s
2025-02-13 02:22:51 -03:00
538aa2e7e8
fixup! feat: func to rotate the IP address from gluetun automatically depending of the traffic
All checks were successful
CI / build (push) Successful in 53s
2025-02-13 02:15:47 -03:00
2f88b8487b
feat: func to rotate the IP address from gluetun automatically depending of the traffic
All checks were successful
CI / build (push) Successful in 48s
2025-02-13 02:00:57 -03:00
21 changed files with 1112 additions and 1024 deletions

View file

@ -2,8 +2,9 @@ name: "CI"
on:
workflow_dispatch:
inputs: {}
push:
branches: ["main", "master"]
branches: ["*"]
jobs:
build:
@ -13,6 +14,21 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v4
# - name: Set up QEMU for ARM64 builds
# uses: docker/setup-qemu-action@v3
# with:
# platforms: arm64
- name: Cache Go packages
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-golang-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-golang-
- name: Setup Docker BuildX system
uses: docker/setup-buildx-action@v3
@ -32,12 +48,11 @@ jobs:
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
- name: Build and push Docker
- name: Build and push Docker AMD64/ARM64
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
# platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1,7 +1,6 @@
FROM golang:alpine3.21 AS build
WORKDIR /app/
RUN go env -w GOMODCACHE=/root/.cache/go-build
RUN apk add --no-cache build-base libwebp-dev git
@ -9,7 +8,7 @@ COPY .git .git
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -ldflags "-s -w -X 'main.version=$(date '+%Y-%m-%d')-$(git rev-list --abbrev-commit -1 HEAD)'" ./cmd/http3-ytproxy
go build -ldflags "-s -w -X 'main.version=$(date '+%Y-%m-%d')-$(git rev-list --abbrev-commit -1 HEAD)'"
FROM alpine:3.21

View file

@ -1,226 +0,0 @@
package main
import (
"flag"
"io"
"log"
"net"
"net/http"
"os"
"runtime"
"strings"
"syscall"
"time"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/config"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/httpc"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/metrics"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/paths"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/utils"
"github.com/prometheus/procfs"
)
type ConnectionWatcher struct {
totalEstablished int64
established int64
active int64
idle int64
}
var version string
var cw ConnectionWatcher
var tx uint64
// 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:
metrics.Metrics.EstablishedConnections.Inc()
metrics.Metrics.TotalConnEstablished.Inc()
case http.StateClosed, http.StateHijacked:
metrics.Metrics.EstablishedConnections.Dec()
}
}
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 = httpc.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 utils.PanicHandler(w)
next(w, req)
}
}
func beforeProxy(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
defer utils.PanicHandler(w)
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 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
}
metrics.Metrics.RequestCount.Inc()
next(w, req)
}
}
func init() {
config.LoadConfig()
}
func main() {
flag.BoolVar(&config.Cfg.Enable_http, "http", config.Cfg.Enable_http, "Enable HTTP Server")
flag.BoolVar(&config.Cfg.Uds, "uds", config.Cfg.Uds, "Enable UDS (Unix socket domain)")
flag.IntVar(&config.Cfg.Http_client_ver, "http-client-ver", config.Cfg.Http_client_ver, "Specify the HTTP Version that is going to be used on the client, accepted values are '1', '2 'and '3'")
flag.BoolVar(&config.Cfg.Ipv6_only, "ipv6-only", config.Cfg.Ipv6_only, "Only use ipv6 for requests")
flag.StringVar(&config.Cfg.Uds_path, "s", config.Cfg.Uds_path, "Specify the UDS (Unix socket domain) path\nExample: /run/http3-ytproxy.sock")
flag.StringVar(&config.Cfg.Proxy, "pr", config.Cfg.Proxy, "Specify the proxy that is going to be used for requests\nExample: http://127.0.0.1:8090")
flag.StringVar(&config.Cfg.Port, "p", config.Cfg.Port, "Specify a port number")
flag.StringVar(&config.Cfg.Host, "l", config.Cfg.Host, "Specify a listen address")
flag.Parse()
log.Printf("[INFO] Current config values: %+v\n", config.Cfg)
switch config.Cfg.Http_client_ver {
case 1:
log.Println("[INFO] Using HTTP/1.1 Client")
httpc.Client = httpc.H1_1client
case 2:
log.Println("[INFO] Using HTTP/2 Client")
httpc.Client = httpc.H2client
case 3:
log.Println("[INFO] Using HTTP/3 Client")
httpc.Client = httpc.H3client
default:
log.Println("[INFO] Using HTTP/1.1 Client")
httpc.Client = httpc.H1_1client
}
mux := http.NewServeMux()
// MISC ROUTES
mux.HandleFunc("/", beforeMisc(paths.Root))
mux.HandleFunc("/health", beforeMisc(paths.Health))
metrics.Register()
mux.Handle("/metrics", paths.MetricsHandler())
// PROXY ROUTES
mux.HandleFunc("/videoplayback", beforeProxy(paths.Videoplayback))
mux.HandleFunc("/vi/", beforeProxy(paths.Vi))
mux.HandleFunc("/vi_webp/", beforeProxy(paths.Vi))
mux.HandleFunc("/sb/", beforeProxy(paths.Vi))
mux.HandleFunc("/ggpht/", beforeProxy(paths.Ggpht))
mux.HandleFunc("/a/", beforeProxy(paths.Ggpht))
mux.HandleFunc("/ytc/", beforeProxy(paths.Ggpht))
if config.Cfg.Gluetun.Block_checker {
go blockChecker(config.Cfg.Gluetun.Gluetun_api, config.Cfg.Gluetun.Block_checker_cooldown)
}
srv := &http.Server{
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 1 * time.Hour,
ConnState: cw.OnStateChange,
Addr: config.Cfg.Host + ":" + config.Cfg.Port,
}
if config.Cfg.Uds {
syscall.Unlink(config.Cfg.Uds_path)
socket_listener, err := net.Listen("unix", config.Cfg.Uds_path)
if err != nil {
log.Println("[ERROR] Failed to bind to UDS, please check the socket path", err.Error())
}
defer socket_listener.Close()
err = os.Chmod(config.Cfg.Uds_path, 0777)
if err != nil {
log.Println("[ERROR] Failed to set socket permissions to 777:", err.Error())
return
} else {
log.Println("[INFO] Setting socket permissions to 777")
}
go func() {
err := srv.Serve(socket_listener)
if err != nil {
log.Println("[ERROR] Failed to listen serve UDS:", err)
}
}()
// To allow everyone to access the socket
log.Println("[INFO] Unix socket listening at:", config.Cfg.Uds_path)
}
if config.Cfg.Enable_http {
log.Println("[INFO] Serving HTTP server at port", config.Cfg.Port)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("[FATAL] Failed to listen on '%s:%s': %s\n", config.Cfg.Host, config.Cfg.Port, err)
}
}
}

16
go.mod
View file

@ -1,23 +1,24 @@
module git.nadeko.net/Fijxu/http3-ytproxy
module git.nadeko.net/Fijxu/http3-ytproxy/v3
go 1.24
go 1.22.0
require (
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/procfs v0.15.1
github.com/quic-go/quic-go v0.48.1
)
toolchain go1.23.0
require github.com/quic-go/quic-go v0.48.1
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/conduitio/bwlimit v0.1.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/onsi/ginkgo/v2 v2.20.2 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
@ -27,6 +28,7 @@ require (
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

42
go.sum
View file

@ -2,20 +2,32 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/conduitio/bwlimit v0.1.0 h1:x3ijON0TSghQob4tFKaEvKixFmYKfVJQeSpXluC2JvE=
github.com/conduitio/bwlimit v0.1.0/go.mod h1:E+ASZ1/5L33MTb8hJTERs5Xnmh6Ulq3jbRh7LrdbXWU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e h1:v7R0PZoC2p1KWQmv1+GqCXQe59Ab1TkDF8Y9Lg2W6m4=
github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE=
github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kolesa-team/go-webp v1.0.4 h1:wQvU4PLG/X7RS0vAeyhiivhLRoxfLVRlDq4I3frdxIQ=
github.com/kolesa-team/go-webp v1.0.4/go.mod h1:oMvdivD6K+Q5qIIkVC2w4k2ZUnI1H+MyP7inwgWq9aA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4=
@ -34,31 +46,57 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.47.0 h1:yXs3v7r2bm1wmPTYNLKAAJTHMYkPEsfYJmTazXrCZ7Y=
github.com/quic-go/quic-go v0.47.0/go.mod h1:3bCapYsJvXGZcipOHuu7plYtaV6tnF+z7wIFsU0WK9E=
github.com/quic-go/quic-go v0.48.1 h1:y/8xmfWI9qmGTc+lBr4jKRUWLGSlSigv847ULJ4hYXA=
github.com/quic-go/quic-go v0.48.1/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

369
httppaths.go Normal file
View file

@ -0,0 +1,369 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync/atomic"
"time"
)
func forbiddenChecker(resp *http.Response, w http.ResponseWriter) error {
if resp.StatusCode == 403 {
w.WriteHeader(403)
io.WriteString(w, "Forbidden 403\n")
io.WriteString(w, "Maybe Youtube blocked the IP of this proxy?\n")
return fmt.Errorf("%s returned %d", resp.Request.Host, resp.StatusCode)
}
return nil
}
func connectionChecker(ctx context.Context) bool {
// To check if the connection has been closed. To prevent
// doing a useless request to google servers
select {
case <-ctx.Done():
return true
default:
return false
}
}
func videoplayback(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
// log.Println(req.URL.RawQuery)
expire, err := strconv.ParseInt(q.Get("expire"), 10, 64)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Expire query string undefined")
return
}
// Prevent the process of already expired playbacks
// since they will return 403 from googlevideo server
if (expire - time.Now().Unix()) <= 0 {
w.WriteHeader(403)
io.WriteString(w, "Videoplayback URL has expired.")
return
}
// c := q.Get("c")
// if c == "" {
// w.WriteHeader(400)
// io.WriteString(w, "'c' query string undefined.")
// return
// }
host := q.Get("host")
q.Del("host")
// if len(host) <= 0 {
// // Fallback to use mvi and mn to build a host
// mvi := q.Get("mvi")
// mn := strings.Split(q.Get("mn"), ",")
// if len(mvi) <= 0 {
// w.WriteHeader(400)
// io.WriteString(w, "'mvi' query string undefined")
// return
// }
// if len(mn) <= 0 {
// w.WriteHeader(400)
// io.WriteString(w, "'mn' query string undefined")
// return
// }
// host = "rr" + mvi + "---" + mn[0] + ".googlevideo.com"
// }
parts := strings.Split(strings.ToLower(host), ".")
if len(parts) < 2 {
w.WriteHeader(400)
io.WriteString(w, "Invalid hostname.")
return
}
domain := parts[len(parts)-2] + "." + parts[len(parts)-1]
disallowed := true
for _, value := range allowed_hosts {
if domain == value {
disallowed = false
break
}
}
if disallowed {
w.WriteHeader(401)
io.WriteString(w, "Non YouTube domains are not supported.")
return
}
// if c == "WEB" {
// q.Set("alr", "yes")
// }
// if req.Header.Get("Range") != "" {
// q.Set("range", req.Header.Get("Range"))
// }
path := req.URL.EscapedPath()
// fmt.Println("esc path:", path)
proxyURL, err := url.Parse("https://" + host + path)
if err != nil {
log.Panic(err)
}
// fmt.Println("query params:", q)
proxyURL.RawQuery = q.Encode()
// fmt.Println("esc path:", proxyURL.RawQuery)
// https://github.com/FreeTubeApp/FreeTube/blob/5a4cd981cdf2c2a20ab68b001746658fd0c6484e/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1097
body := []byte{0x78, 0} // protobuf body
postRequest, err := http.NewRequest("POST", proxyURL.String(), bytes.NewReader(body))
if err != nil {
log.Panic("Failed to create postRequest:", err)
}
// headRequest, err := http.Head(proxyURL.String())
headRequest, err := http.NewRequest("HEAD", proxyURL.String(), nil)
if err != nil {
log.Panic("Failed to create headRequest:", err)
}
copyHeaders(req.Header, postRequest.Header, false)
copyHeaders(req.Header, headRequest.Header, false)
// switch c {
// case "ANDROID":
// request.Header.Set("User-Agent", "com.google.android.youtube/1537338816 (Linux; U; Android 13; en_US; ; Build/TQ2A.230505.002; Cronet/113.0.5672.24)")
// case "IOS":
// request.Header.Set("User-Agent", "com.google.ios.youtube/19.32.8 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)")
// case "WEB":
// request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
// default:
// request.Header.Set("User-Agent", default_ua)
// }
// request.Header.Add("Origin", "https://www.youtube.com")
// request.Header.Add("Referer", "https://www.youtube.com/")
// if connectionChecker(req.Context()) {
// return
// }
resp := &http.Response{}
// doPostRequest := func () {
// }
for i := 0; i < 5; i++ {
// log.Println("headRequest URL:", headRequest.URL)
resp, err = client.Do(headRequest)
if err != nil {
log.Panic("Failed to do HEAD request:", err)
}
// log.Println("HEAD request made to:", headRequest.URL, "returned", strconv.Itoa(resp.StatusCode))
// log.Println("HEAD Location header:", resp.Header.Get("Location"))
if resp.Header.Get("Location") != "" {
log.Println("")
location := resp.Header.Get("Location")
// log.Println("location:", location)
new_url, _ := url.Parse(location)
new_host := new_url.Host
log.Println("new_host:", new_host)
// log.Println("new_url:", new_url.Host)
// log.Println("old_request:", proxyURL.String())
postRequest.URL = new_url
headRequest.URL = new_url
// headRequest.Header.Set("Host", new_url.Host)
// postRequest.Header.Set("Host", new_url.Host)
postRequest.Host = new_url.Host
headRequest.Host = new_url.Host
log.Println("postRequest.Host after location:", postRequest.Host)
log.Println("headRequest.Host after location:", headRequest.Host)
// log.Println("postrequest new_url:", postRequest.URL)
// resp, err = client.Do(postRequest)
// if err != nil {
// log.Panic("Failed to do POST request")
// }
continue
} else {
break
}
}
resp, err = client.Do(postRequest)
if err != nil {
log.Panic("Failed to do POST request:", err)
}
defer resp.Body.Close()
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
// resp, err = client.Do(postRequest)
// if err != nil {
// log.Panic("Failed to do POST request:", err)
// }
// if resp.Header.Get("location") != "" {
// new_url, err := url.Parse(resp.Header.Get("location"))
// if err != nil {
// log.Panic(err)
// }
// request.URL = new_url
// resp, err = client.Do(request)
// if err != nil {
// log.Panic(err)
// }
// }
// if err := forbiddenChecker(resp, w); err != nil {
// atomic.AddInt64(&stats_.RequestsForbidden.Videoplayback, 1)
// metrics.RequestForbidden.Videoplayback.Inc()
// return
// }
// defer resp.Body.Close()
// NoRewrite := strings.HasPrefix(resp.Header.Get("Content-Type"), "audio") || strings.HasPrefix(resp.Header.Get("Content-Type"), "video")
// copyHeaders(resp.Header, w.Header(), NoRewrite)
// w.WriteHeader(resp.StatusCode)
// if req.Method == "GET" && (resp.Header.Get("Content-Type") == "application/x-mpegurl" || resp.Header.Get("Content-Type") == "application/vnd.apple.mpegurl") {
// bytes, err := io.ReadAll(resp.Body)
// if err != nil {
// log.Panic(err)
// }
// lines := strings.Split(string(bytes), "\n")
// reqUrl := resp.Request.URL
// for i := 0; i < len(lines); i++ {
// line := lines[i]
// if !strings.HasPrefix(line, "https://") && (strings.HasSuffix(line, ".m3u8") || strings.HasSuffix(line, ".ts")) {
// path := reqUrl.EscapedPath()
// path = path[0 : strings.LastIndex(path, "/")+1]
// line = "https://" + reqUrl.Hostname() + path + line
// }
// if strings.HasPrefix(line, "https://") {
// lines[i] = RelativeUrl(line)
// }
// if manifest_re.MatchString(line) {
// url := manifest_re.FindStringSubmatch(line)[1]
// lines[i] = strings.Replace(line, url, RelativeUrl(url), 1)
// }
// }
// io.WriteString(w, strings.Join(lines, "\n"))
// } else {
io.Copy(w, resp.Body)
// }
}
func vi(w http.ResponseWriter, req *http.Request) {
const host string = "i.ytimg.com"
q := req.URL.Query()
path := req.URL.EscapedPath()
proxyURL, err := url.Parse("https://" + host + path)
if err != nil {
log.Panic(err)
}
if strings.HasSuffix(proxyURL.EscapedPath(), "maxres.jpg") {
proxyURL.Path = getBestThumbnail(proxyURL.EscapedPath())
}
/*
Required for /sb/ endpoints
You can't access https://i.ytimg.com/sb/<VIDEOID>/storyboard3_L2/M3.jpg
without it's parameters `sqp` and `sigh`
*/
proxyURL.RawQuery = q.Encode()
request, err := http.NewRequest(req.Method, proxyURL.String(), nil)
if err != nil {
log.Panic(err)
}
request.Header.Set("User-Agent", default_ua)
if connectionChecker(req.Context()) {
return
}
resp, err := client.Do(request)
if err != nil {
log.Panic(err)
}
if err := forbiddenChecker(resp, w); err != nil {
atomic.AddInt64(&stats_.RequestsForbidden.Vi, 1)
metrics.RequestForbidden.Vi.Inc()
return
}
defer resp.Body.Close()
// NoRewrite := strings.HasPrefix(resp.Header.Get("Content-Type"), "audio") || strings.HasPrefix(resp.Header.Get("Content-Type"), "video")
// copyHeaders(resp.Header, w.Header(), NoRewrite)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func ggpht(w http.ResponseWriter, req *http.Request) {
const host string = "yt3.ggpht.com"
path := req.URL.EscapedPath()
path = strings.Replace(path, "/ggpht", "", 1)
path = strings.Replace(path, "/i/", "/", 1)
proxyURL, err := url.Parse("https://" + host + path)
if err != nil {
log.Panic(err)
}
request, err := http.NewRequest(req.Method, proxyURL.String(), nil)
copyHeaders(req.Header, request.Header, false)
request.Header.Set("User-Agent", default_ua)
if err != nil {
log.Panic(err)
}
if connectionChecker(req.Context()) {
return
}
resp, err := client.Do(request)
if err != nil {
log.Panic(err)
}
if err := forbiddenChecker(resp, w); err != nil {
atomic.AddInt64(&stats_.RequestsForbidden.Ggpht, 1)
metrics.RequestForbidden.Ggpht.Inc()
return
}
defer resp.Body.Close()
NoRewrite := strings.HasPrefix(resp.Header.Get("Content-Type"), "audio") || strings.HasPrefix(resp.Header.Get("Content-Type"), "video")
copyHeaders(resp.Header, w.Header(), NoRewrite)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

View file

@ -1,102 +0,0 @@
package config
import (
"log"
"strconv"
"strings"
"syscall"
)
var Cfg *config
type config struct {
Enable_http bool
Uds bool
Uds_path string
Host string
Port string
Proxy string
Http_client_ver int
Ipv6_only bool
Gluetun struct {
Gluetun_api string
Block_checker bool
Block_checker_cooldown int
}
Companion struct {
// Used for videoplayback query encryption
Secret_key string
}
}
func getenv(key string) string {
// `YTPROXY_` as a prefix
v, _ := syscall.Getenv("YTPROXY_" + key)
return v
}
func getEnvBool(key string, def bool) bool {
v := strings.ToLower(getenv(key))
if v == "" {
return def
}
return v == "true"
}
func getEnvString(key string, def string, tolower bool) string {
var v string
if tolower {
v = strings.ToLower(getenv(key))
}
v = getenv(key)
if v == "" {
return def
}
return v
}
func getEnvInt(key string, def int) int {
v := strings.ToLower(getenv(key))
if v == "" {
return def
}
i, err := strconv.Atoi(v)
if err != nil {
log.Panicf("[FATAL] Failed to convert env variable '%s' to int", v)
}
return int(i)
}
func LoadConfig() {
Cfg = &config{
Enable_http: getEnvBool("ENABLE_HTTP", true),
Uds: getEnvBool("ENABLE_UDS", true),
// I would use `/run/http3-proxy` here, but `/run` is not user writable
// which is kinda anoying when developing.
Uds_path: getEnvString("UDS_PATH", "/tmp/http-ytproxy.sock", true),
Host: getEnvString("HOST", "0.0.0.0", true),
Port: getEnvString("PORT", "8080", true),
Proxy: getEnvString("PROXY", "", true),
Http_client_ver: getEnvInt("HTTP_CLIENT_VER", 1),
Ipv6_only: getEnvBool("IPV6_ONLY", false),
Gluetun: struct {
Gluetun_api string
Block_checker bool
Block_checker_cooldown int
}{
Gluetun_api: getEnvString("GLUETUN_API", "127.0.0.1:8000", true),
Block_checker: getEnvBool("BLOCK_CHECKER", true),
Block_checker_cooldown: getEnvInt("BLOCK_CHECKER_COOLDOWN", 60),
},
Companion: struct{ Secret_key string }{
Secret_key: getEnvString("SECRET_KEY", "", false),
},
}
checkConfig()
}
func checkConfig() {
if len(Cfg.Companion.Secret_key) > 16 {
log.Fatalln("The value of 'companion.secret_key' (YTPROXY_SECRET_KEY) needs to be a size of 16 characters.")
}
}

View file

@ -1,93 +0,0 @@
package httpc
import (
"crypto/tls"
"net"
"net/http"
"net/url"
"time"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/config"
"github.com/quic-go/quic-go/http3"
)
var Client *http.Client
var dialer = &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
// 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,
}
// http/1.1 client
var H1_1client = &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 config.Cfg.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,
MaxConnsPerHost: 0,
MaxIdleConnsPerHost: 10,
MaxIdleConns: 0,
Proxy: func(r *http.Request) (*url.URL, error) {
if config.Cfg.Proxy != "" {
return url.Parse(config.Cfg.Proxy)
}
return nil, nil
},
// Prevent switching to HTTP/2
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
},
}
// 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 config.Cfg.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 config.Cfg.Proxy != "" {
return url.Parse(config.Cfg.Proxy)
}
return nil, nil
},
},
}

View file

@ -1 +0,0 @@
package httpc

View file

@ -1,76 +0,0 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
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 Register() {
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)
}

View file

@ -1,33 +0,0 @@
package paths
import (
"net/http"
"regexp"
)
const (
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"
ggpht_host = "yt3.ggpht.com"
)
var manifest_re = regexp.MustCompile(`(?m)URI="([^"]+)"`)
var allowed_hosts = []string{
"youtube.com",
"googlevideo.com",
"ytimg.com",
"ggpht.com",
"googleusercontent.com",
}
var videoplayback_headers = &http.Header{
"Accept": {"*/*"},
"Accept-Encoding": {"gzip, deflate, br, zstd"},
"Accept-Language": {"en-us,en;q=0.5"},
"Origin": {"https://www.youtube.com"},
"Referer": {"https://www.youtube.com/"},
"User-Agent": {default_ua},
}
// https://github.com/FreeTubeApp/FreeTube/blob/5a4cd981cdf2c2a20ab68b001746658fd0c6484e/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1097
var protobuf_body = []byte{0x78, 0} // protobuf body

View file

@ -1,49 +0,0 @@
package paths
import (
"io"
"log"
"net/http"
"net/url"
"strings"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/httpc"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/metrics"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/utils"
)
func Ggpht(w http.ResponseWriter, req *http.Request) {
path := req.URL.EscapedPath()
path = strings.Replace(path, "/ggpht", "", 1)
path = strings.Replace(path, "/i/", "/", 1)
proxyURL, err := url.Parse("https://" + ggpht_host + path)
if err != nil {
log.Panic(err)
}
request, err := http.NewRequest(req.Method, proxyURL.String(), nil)
utils.CopyHeaders(req.Header, request.Header, false)
request.Header.Set("User-Agent", default_ua)
if err != nil {
log.Panic(err)
}
resp, err := httpc.Client.Do(request)
if err != nil {
log.Panic(err)
}
if err := forbiddenChecker(resp, w); err != nil {
metrics.Metrics.RequestForbidden.Ggpht.Inc()
return
}
defer resp.Body.Close()
NoRewrite := strings.HasPrefix(resp.Header.Get("Content-Type"), "audio") || strings.HasPrefix(resp.Header.Get("Content-Type"), "video")
utils.CopyHeaders(resp.Header, w.Header(), NoRewrite)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

View file

@ -1,11 +0,0 @@
package paths
import (
"io"
"net/http"
)
func Health(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(200)
io.WriteString(w, "OK")
}

View file

@ -1,27 +0,0 @@
package paths
import (
"net"
"net/http"
"strings"
"time"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/metrics"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var programInit = time.Now()
// 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.Metrics.Uptime.Set(float64(time.Duration(time.Since(programInit).Seconds())))
promhttp.Handler().ServeHTTP(w, req)
})
}

View file

@ -1,17 +0,0 @@
package paths
import (
"io"
"net/http"
)
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)
}

View file

@ -1,59 +0,0 @@
package paths
import (
"io"
"log"
"net/http"
"net/url"
"strings"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/httpc"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/metrics"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/utils"
)
func Vi(w http.ResponseWriter, req *http.Request) {
const host string = "i.ytimg.com"
q := req.URL.Query()
path := req.URL.EscapedPath()
proxyURL, err := url.Parse("https://" + host + path)
if err != nil {
log.Panic(err)
}
if strings.HasSuffix(proxyURL.EscapedPath(), "maxres.jpg") {
proxyURL.Path = utils.GetBestThumbnail(proxyURL.EscapedPath())
}
/*
Required for /sb/ endpoints
You can't access https://i.ytimg.com/sb/<VIDEOID>/storyboard3_L2/M3.jpg
without it's parameters `sqp` and `sigh`
*/
proxyURL.RawQuery = q.Encode()
request, err := http.NewRequest(req.Method, proxyURL.String(), nil)
if err != nil {
log.Panic(err)
}
request.Header.Set("User-Agent", default_ua)
resp, err := httpc.Client.Do(request)
if err != nil {
log.Panic(err)
}
if err := forbiddenChecker(resp, w); err != nil {
metrics.Metrics.RequestForbidden.Vi.Inc()
return
}
defer resp.Body.Close()
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

View file

@ -1,244 +0,0 @@
package paths
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/config"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/httpc"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/metrics"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/utils"
)
func forbiddenChecker(resp *http.Response, w http.ResponseWriter) error {
if resp.StatusCode == 403 {
w.WriteHeader(403)
io.WriteString(w, "Forbidden 403\n")
io.WriteString(w, "Maybe Youtube blocked the IP of this proxy?\n")
return fmt.Errorf("%s returned %d", resp.Request.Host, resp.StatusCode)
}
return nil
}
func checkRequest(w http.ResponseWriter, req *http.Request, params url.Values) bool {
host := params.Get("host")
parts := strings.Split(strings.ToLower(host), ".")
if len(parts) < 2 {
w.WriteHeader(400)
io.WriteString(w, "Invalid hostname.")
return true
}
domain := parts[len(parts)-2] + "." + parts[len(parts)-1]
disallowed := true
for _, value := range allowed_hosts {
if domain == value {
disallowed = false
break
}
}
if disallowed {
w.WriteHeader(401)
io.WriteString(w, "Non YouTube domains are not supported.")
return true
}
expire, err := strconv.ParseInt(params.Get("expire"), 10, 64)
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Expire query string undefined")
return true
}
// Prevent the process of already expired playbacks
// since they will return 403 from googlevideo server
if (expire - time.Now().Unix()) <= 0 {
w.WriteHeader(403)
io.WriteString(w, "Videoplayback URL has expired.")
return true
}
return false
}
func Videoplayback(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
if q.Get("enc") == "true" {
decryptedQueryParams, err := utils.DecryptQueryParams(req.URL.Query().Get("data"), config.Cfg.Companion.Secret_key)
if err != nil {
http.Error(w, "Internal Server Error:\nFailed to decrypt query parameters", http.StatusInternalServerError)
return
}
var structuredDecryptedQueryParams [][]string
err = json.Unmarshal([]byte(decryptedQueryParams), &structuredDecryptedQueryParams)
if err != nil {
http.Error(w, "Internal Server Error:\nFailed to parse query parameters from the decrypted query parameters", http.StatusInternalServerError)
return
}
pot := structuredDecryptedQueryParams[1][1]
ip := structuredDecryptedQueryParams[0][1]
q.Del("enc")
q.Del("data")
q.Set("pot", pot)
q.Set("ip", ip)
}
if checkRequest(w, req, q) {
return
}
host := q.Get("host")
title := q.Get("title")
q.Del("host")
q.Del("title")
rangeHeader := req.Header.Get("range")
var requestBytes string
if rangeHeader != "" {
requestBytes = strings.Split(rangeHeader, "=")[1]
} else {
requestBytes = ""
}
if requestBytes != "" {
q.Set("range", requestBytes)
}
path := req.URL.EscapedPath()
proxyURL, err := url.Parse("https://" + host + path)
if err != nil {
log.Panic(err)
}
proxyURL.RawQuery = q.Encode()
postRequest, err := http.NewRequest("POST", proxyURL.String(), bytes.NewReader(protobuf_body))
if err != nil {
log.Panic("Failed to create postRequest:", err)
}
headRequest, err := http.NewRequest("HEAD", proxyURL.String(), nil)
if err != nil {
log.Panic("Failed to create headRequest:", err)
}
postRequest.Header = *videoplayback_headers
headRequest.Header = *videoplayback_headers
resp := &http.Response{}
for i := 0; i < 5; i++ {
resp, err = httpc.Client.Do(headRequest)
if err != nil {
log.Panic("Failed to do HEAD request:", err)
}
if resp.Header.Get("Location") != "" {
new_url, _ := url.Parse(resp.Header.Get("Location"))
postRequest.URL = new_url
headRequest.URL = new_url
postRequest.Host = new_url.Host
headRequest.Host = new_url.Host
continue
} else {
break
}
}
resp, err = httpc.Client.Do(postRequest)
if err != nil {
log.Panic("Failed to do POST request:", err)
}
if err := forbiddenChecker(resp, w); err != nil {
metrics.Metrics.RequestForbidden.Videoplayback.Inc()
return
}
defer resp.Body.Close()
utils.CopyHeadersNew(resp.Header, w.Header())
if title != "" {
content := "attachment; filename=\"" + url.PathEscape(title) + "\"; filename*=UTF-8''" + url.PathEscape(title)
w.Header().Set("content-disposition", content)
}
if requestBytes != "" && resp.StatusCode == http.StatusOK {
// check for range headers in the forms:
// "bytes=0-" get full length from start
// "bytes=500-" get full length from 500 bytes in
// "bytes=500-1000" get 500 bytes starting from 500
byteParts := strings.Split(requestBytes, "-")
firstByte, lastByte := byteParts[0], byteParts[1]
if lastByte != "" {
clen := q.Get("clen")
if clen == "" {
w.Header().Add("content-range", "bytes "+requestBytes+"/*")
} else {
w.Header().Add("content-range", "bytes "+requestBytes+"/"+clen)
}
w.WriteHeader(206)
} else {
// i.e. "bytes=0-", "bytes=600-"
// full size of content is able to be calculated, so a full Content-Range header can be constructed
bytesReceived := resp.Header.Get("content-length")
firstByteInt, _ := strconv.Atoi(firstByte)
bytesReceivedInt, _ := strconv.Atoi(bytesReceived)
// last byte should always be one less than the length
totalContentLength := firstByteInt + bytesReceivedInt
lastByte := totalContentLength - 1
lastByteString := strconv.Itoa(lastByte)
totalContentLengthString := strconv.Itoa(totalContentLength)
w.Header().Add("content-range", "bytes "+firstByte+"-"+lastByteString+"/"+totalContentLengthString)
if firstByte != "0" {
// only part of the total content returned, 206
w.WriteHeader(206)
}
}
}
if req.Method == "GET" && (resp.Header.Get("Content-Type") == "application/x-mpegurl" || resp.Header.Get("Content-Type") == "application/vnd.apple.mpegurl") {
bytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Panic(err)
}
lines := strings.Split(string(bytes), "\n")
reqUrl := resp.Request.URL
for i := 0; i < len(lines); i++ {
line := lines[i]
if !strings.HasPrefix(line, "https://") && (strings.HasSuffix(line, ".m3u8") || strings.HasSuffix(line, ".ts")) {
path := reqUrl.EscapedPath()
path = path[0 : strings.LastIndex(path, "/")+1]
line = "https://" + reqUrl.Hostname() + path + line
}
if strings.HasPrefix(line, "https://") {
lines[i] = utils.RelativeUrl(line)
}
if manifest_re.MatchString(line) {
url := manifest_re.FindStringSubmatch(line)[1]
lines[i] = strings.Replace(line, url, utils.RelativeUrl(url), 1)
}
}
io.WriteString(w, strings.Join(lines, "\n"))
} else {
io.Copy(w, resp.Body)
}
}

View file

@ -1,28 +0,0 @@
package utils
const (
path_prefix = ""
)
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 headers_for_response = []string{
"Content-Length",
"Accept-Ranges",
"Content-Type",
"Expires",
"Last-Modified",
}

650
main.go Normal file
View file

@ -0,0 +1,650 @@
package main
import (
"crypto/tls"
"crypto/x509"
"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{
EnableDatagrams: false,
},
Timeout: 10 * time.Second,
}
var dialer = &net.Dialer{
Timeout: 30 * time.Second,
}
var proxy string
// var proxyUrl, _ = url.Parse("http://127.0.0.1:8080")
var proxyUrl, _ = url.Parse("socks5://127.0.0.1:1080")
var protocols *http.Protocols
// http/2 client
var h2client = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { return nil }},
// Proxy: http.ProxyURL(proxyUrl),
},
}
// var h2client_test = &http.Client{
// // Yeah I know I can just use http.Transport and it will use HTTP/2 automatically
// // I prefer to set it up explicitly
// Transport: &http.Transport{},
// }
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() {
protocols = &http.Protocols{}
protocols.SetHTTP1(true)
protocols.SetHTTP2(false)
protocols.SetUnencryptedHTTP2(false)
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 {
log.Println("[INFO] Using HTTP/3 client")
client = h3client
} else {
log.Println("[INFO] Using HTTP/2 client")
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)
}
}
}
}

19
nginx.conf Normal file
View file

@ -0,0 +1,19 @@
user www-data;
events {
worker_connections 1024;
}
http {
server {
listen 3000;
listen [::]:3000;
access_log off;
location / {
resolver 127.0.0.11;
set $backend "http3-proxy";
proxy_pass http://$backend:8080;
proxy_http_version 1.1; # to keep alive
proxy_set_header Connection ""; # to keep alive
}
}
}

View file

@ -1,17 +1,13 @@
package utils
package main
import (
"crypto/aes"
"encoding/base64"
"log"
"net/http"
"net/url"
"strings"
"git.nadeko.net/Fijxu/http3-ytproxy/internal/httpc"
)
func CopyHeaders(from http.Header, to http.Header, length bool) {
func copyHeaders(from http.Header, to http.Header, length bool) {
// Loop over header names
outer:
for name, values := range from {
@ -32,24 +28,14 @@ outer:
}
}
func CopyHeadersNew(from http.Header, to http.Header) {
for from_header, value := range from {
for _, header := range headers_for_response {
if from_header == header {
to.Add(header, value[0])
}
}
}
}
func GetBestThumbnail(path string) (newpath string) {
func getBestThumbnail(path string) (newpath string) {
formats := [4]string{"maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg"}
for _, format := range formats {
newpath = strings.Replace(path, "maxres.jpg", format, 1)
url := "https://i.ytimg.com" + newpath
resp, _ := httpc.Client.Head(url)
resp, _ := h2client.Head(url)
if resp.StatusCode == 200 {
return newpath
}
@ -70,33 +56,9 @@ func RelativeUrl(in string) (newurl string) {
return segment_url.RequestURI()
}
func PanicHandler(w http.ResponseWriter) {
func panicHandler(w http.ResponseWriter) {
if r := recover(); r != nil {
log.Printf("Panic: %v", r)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// https://stackoverflow.com/a/41652605
func DecryptQueryParams(encryptedQuery string, key string) (string, error) {
se, err := base64.StdEncoding.DecodeString(encryptedQuery)
if err != nil {
log.Println("[ERROR] Error when decoding base64 string:", err)
return "", err
}
cipher, err := aes.NewCipher([]byte(key)[0:16])
if err != nil {
log.Println("[ERROR] Error initializating cipher.Block:", err)
return "", err
}
decrypted := make([]byte, len(se))
size := 16
for bs, be := 0, size; bs < len(se); bs, be = bs+size, be+size {
cipher.Decrypt(decrypted[bs:be], se[bs:be])
}
paddingSize := int(decrypted[len(decrypted)-1])
return string(decrypted[0 : len(decrypted)-paddingSize]), nil
}