Compare commits

..

30 commits

Author SHA1 Message Date
a6a33526a4
ci: enable docker build cache
All checks were successful
CI / build (push) Successful in 1m28s
2025-04-24 18:44:55 -04:00
dcb87b225a
fix: Fix content-range header in video proxy for partial requests
from: https://github.com/iv-org/invidious-companion/pull/101
2025-04-24 18:42:10 -04:00
a9c845651d
forgot to delete those println xd
All checks were successful
CI / build (push) Successful in 1m1s
2025-03-28 23:47:32 -03:00
71aa6edd67
fix: handling range headers correctly when full content requested
All checks were successful
CI / build (push) Successful in 58s
From: 71341b894c
2025-03-28 02:03:53 -03:00
356e523bc8
enforce 16 characters for companion.secret_key
All checks were successful
CI / build (push) Successful in 59s
ref: 23ff6135bb
2025-03-27 22:11:51 -03:00
e13b2e02a7
fix checkRequest checking 2025-03-27 22:07:09 -03:00
cb370138b8
change how the encrypted parameters are handled.
based on 7a56a46864
2025-03-27 22:06:56 -03:00
235a2c48a6 feat: display the current values of the configuration on start
All checks were successful
CI / build (push) Successful in 57s
2025-03-24 21:54:09 -03:00
6a8c4ea562 fix: do not tolower on secret_key 2025-03-24 21:53:47 -03:00
76e9807a34 fix: add missing secret_key config value
All checks were successful
CI / build (push) Successful in 1m9s
2025-03-24 21:45:07 -03:00
40faa994da style: move configuration to it's own package and more
All checks were successful
CI / build (push) Successful in 51s
- Remove https, it's useless and reverse proxies like haproxy, caddy and
  nginx are better at handling TCP and SSL connections
- Ability to disable the UDS and HTTP servers
2025-03-24 18:36:50 -03:00
6e970fcb43 update go.mod 2025-03-24 15:03:03 -03:00
6bd0f28d77
feat: add support for encrypted query parameters
All checks were successful
CI / build (push) Successful in 1m18s
2025-03-14 22:17:23 -03:00
81aa259a31
use http/1.1 by default
All checks were successful
CI / build (push) Successful in 1m4s
http/2 is not useful for DASH video streaming.
2025-03-10 22:53:25 -03:00
83fff3d861
remove client check since it's not needed for now
Some checks failed
CI / build (push) Failing after 16s
2025-03-10 15:07:42 -03:00
77c8730391
fix: fix data race on headers
All checks were successful
CI / build (push) Successful in 49s
2025-03-07 01:19:48 -03:00
d268f1a2c2
style: remove dead code in form of comments
All checks were successful
CI / build (push) Successful in 1m9s
2025-03-06 22:25:21 -03:00
3563b4e819
feat: change how headers are passed to the client and move constant variables 2025-03-06 22:25:21 -03:00
f7e75ce5e7
feat: use invidious-companion way to handle Range header and query parameter 2025-03-06 21:31:37 -03:00
1045548164
style: remove dead code comments and move comments
All checks were successful
CI / build (push) Successful in 51s
2025-03-04 15:21:55 -03:00
c50e482085
remove domain only access, that can be done in the reverse proxy side 2025-03-04 15:16:32 -03:00
94edee02d1
remove bandwidth limiter, that is being done in the reverse proxy side 2025-03-04 15:14:10 -03:00
f9b7cf20ed
add option to enable http server or not 2025-03-04 15:10:26 -03:00
6f4567df0c
include prefix for all environment variables
All checks were successful
CI / build (push) Successful in 1m4s
2025-03-04 15:06:13 -03:00
f66aa3efa6
ci: update go build path on Dockerfile and update CI file
All checks were successful
CI / build (push) Successful in 1m38s
2025-02-20 02:12:42 -03:00
340ee021bb
chore: get rid of the stats and only use prometheus metrics
Some checks failed
CI / build (push) Failing after 31s
2025-02-20 01:27:30 -03:00
8821540bd9
style: refactor project to use the standard go project layout 2025-02-20 01:13:40 -03:00
cc4671c677
feat(httppaths): remove connection checker
All checks were successful
CI / build (push) Successful in 59s
I doubt this is even used
2025-02-19 20:01:35 -03:00
319991c7b8
fix(videoplayback): Use HEAD requests to get the location of the videoplayback URL before doing a POST
All checks were successful
CI / build (push) Successful in 1m1s
"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
197a807b90
feat: func to rotate the IP address from gluetun automatically depending of the traffic
this is supposed to execute every second to be able to calulate the difference of the transmitted bytes

fuck

fuck fuck: change block checker cooldown back to 60 seconds
2025-02-19 17:21:59 -03:00
21 changed files with 1024 additions and 1112 deletions

View file

@ -2,9 +2,8 @@ name: "CI"
on:
workflow_dispatch:
inputs: {}
push:
branches: ["*"]
branches: ["main", "master"]
jobs:
build:
@ -14,21 +13,6 @@ 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
@ -48,11 +32,12 @@ 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 AMD64/ARM64
- name: Build and push Docker
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,6 +1,7 @@
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
@ -8,7 +9,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)'"
go build -ldflags "-s -w -X 'main.version=$(date '+%Y-%m-%d')-$(git rev-list --abbrev-commit -1 HEAD)'" ./cmd/http3-ytproxy
FROM alpine:3.21

226
cmd/http3-ytproxy/main.go Normal file
View file

@ -0,0 +1,226 @@
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,24 +1,23 @@
module git.nadeko.net/Fijxu/http3-ytproxy/v3
module git.nadeko.net/Fijxu/http3-ytproxy
go 1.22.0
go 1.24
toolchain go1.23.0
require github.com/quic-go/quic-go v0.48.1
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
)
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
@ -28,7 +27,6 @@ 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,32 +2,20 @@ 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/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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=
@ -46,57 +34,31 @@ 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=

View file

@ -1,369 +0,0 @@
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)
}

102
internal/config/config.go Normal file
View file

@ -0,0 +1,102 @@
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.")
}
}

93
internal/httpc/client.go Normal file
View file

@ -0,0 +1,93 @@
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
},
},
}

1
internal/httpc/server.go Normal file
View file

@ -0,0 +1 @@
package httpc

View file

@ -0,0 +1,76 @@
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)
}

33
internal/paths/consts.go Normal file
View file

@ -0,0 +1,33 @@
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

49
internal/paths/ggpht.go Normal file
View file

@ -0,0 +1,49 @@
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)
}

11
internal/paths/health.go Normal file
View file

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

27
internal/paths/metrics.go Normal file
View file

@ -0,0 +1,27 @@
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)
})
}

17
internal/paths/root.go Normal file
View file

@ -0,0 +1,17 @@
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)
}

59
internal/paths/vi.go Normal file
View file

@ -0,0 +1,59 @@
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

@ -0,0 +1,244 @@
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)
}
}

28
internal/utils/consts.go Normal file
View file

@ -0,0 +1,28 @@
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",
}

View file

@ -1,13 +1,17 @@
package main
package utils
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 {
@ -28,14 +32,24 @@ outer:
}
}
func getBestThumbnail(path string) (newpath string) {
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) {
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, _ := h2client.Head(url)
resp, _ := httpc.Client.Head(url)
if resp.StatusCode == 200 {
return newpath
}
@ -56,9 +70,33 @@ 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
}

650
main.go
View file

@ -1,650 +0,0 @@
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)
}
}
}
}

View file

@ -1,19 +0,0 @@
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
}
}
}