├── .editorconfig ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── cmd └── main.go ├── e2e ├── memtest │ ├── go.mod │ ├── go.sum │ ├── helper.go │ └── main.go └── webbrowser │ ├── Pipfile │ ├── Pipfile.lock │ ├── test_chrome.py │ └── test_firefox.py ├── env.go ├── example ├── echo-server │ ├── Makefile │ ├── README.md │ ├── detail.go │ ├── main.go │ ├── response.go │ └── server.go ├── http2-fingerprint-dos-poc │ └── main.go ├── ja3-raw │ ├── README.md │ └── main.go ├── ja3-sorted-extensions │ ├── README.md │ └── main.go └── my-fingerprint │ ├── README.md │ └── main.go ├── fingerproxy.go ├── flags.go ├── go.mod ├── go.sum ├── pkg ├── README.md ├── certwatcher │ └── certwatcher.go ├── debug │ ├── debug.go │ └── debug_stub.go ├── fingerprint │ ├── fingerprint.go │ └── header_injector.go ├── hack │ ├── channel_listener.go │ ├── hajack_clienthello_conn.go │ └── tls_clienthello_conn.go ├── http2 │ ├── .gitignore │ ├── LICENSE │ ├── ascii.go │ ├── ascii_test.go │ ├── ciphers.go │ ├── ciphers_test.go │ ├── client_conn_pool.go │ ├── clientconn_test.go │ ├── config.go │ ├── config_go124.go │ ├── config_pre_go124.go │ ├── config_test.go │ ├── connframes_test.go │ ├── databuffer.go │ ├── databuffer_test.go │ ├── errors.go │ ├── errors_test.go │ ├── flow.go │ ├── flow_test.go │ ├── frame.go │ ├── frame_test.go │ ├── gate_test.go │ ├── gotrack.go │ ├── gotrack_test.go │ ├── h2c │ │ ├── h2c.go │ │ └── h2c_test.go │ ├── h2i │ │ ├── README.md │ │ └── h2i.go │ ├── headermap.go │ ├── hpack │ │ ├── encode.go │ │ ├── encode_test.go │ │ ├── gen.go │ │ ├── hpack.go │ │ ├── hpack_test.go │ │ ├── huffman.go │ │ ├── static_table.go │ │ ├── tables.go │ │ └── tables_test.go │ ├── http2.go │ ├── http2_test.go │ ├── netconn_test.go │ ├── pipe.go │ ├── pipe_test.go │ ├── server.go │ ├── server_push_test.go │ ├── server_test.go │ ├── sync_test.go │ ├── timer.go │ ├── transport.go │ ├── transport_test.go │ ├── unencrypted.go │ ├── write.go │ ├── writesched.go │ ├── writesched_priority.go │ ├── writesched_priority_test.go │ ├── writesched_random.go │ ├── writesched_random_test.go │ ├── writesched_roundrobin.go │ ├── writesched_roundrobin_test.go │ └── writesched_test.go ├── ja3 │ ├── LICENSE │ ├── ja3.go │ └── sync.sh ├── ja4 │ ├── LICENSE │ ├── helper.go │ ├── ja4.go │ ├── ja4_test.go │ └── types.go ├── ja4pcap │ ├── pcap.go │ ├── pcap_test.go │ ├── sync-testdata.sh │ └── testdata │ │ ├── pcap │ │ ├── CVE-2018-6794.pcap │ │ ├── badcurveball.pcap │ │ ├── browsers-x509.pcapng │ │ ├── chrome-cloudflare-quic-with-secrets.pcapng │ │ ├── gre-erspan-vxlan.pcap │ │ ├── gre-sample.pcap │ │ ├── http1-with-cookies.pcapng │ │ ├── http1.pcapng │ │ ├── http2-with-cookies.pcapng │ │ ├── ipv6.pcapng │ │ ├── latest.pcapng │ │ ├── macos_tcp_flags.pcap │ │ ├── quic-tls-handshake.pcapng │ │ ├── quic-with-several-tls-frames.pcapng │ │ ├── single-packets.pcap │ │ ├── socks4-https.pcap │ │ ├── ssh-r.pcap │ │ ├── ssh-scp-1050.pcap │ │ ├── ssh.pcapng │ │ ├── ssh2-malformed.pcap │ │ ├── ssh2-moloch-crash.pcap │ │ ├── ssh2.pcapng │ │ ├── sshv1.pcap │ │ ├── tcpdump-geneve.pcap │ │ ├── tls-alpn-h2.pcap │ │ ├── tls-handshake.pcapng │ │ ├── tls-non-ascii-alpn.pcapng │ │ ├── tls-sni.pcapng │ │ ├── tls12.pcap │ │ ├── tls3.pcapng │ │ └── v6.pcap │ │ └── snapshots │ │ ├── ja4__insta@CVE-2018-6794.pcap.snap │ │ ├── ja4__insta@badcurveball.pcap.snap │ │ ├── ja4__insta@browsers-x509.pcapng.snap │ │ ├── ja4__insta@chrome-cloudflare-quic-with-secrets.pcapng.snap │ │ ├── ja4__insta@gre-erspan-vxlan.pcap.snap │ │ ├── ja4__insta@gre-sample.pcap.snap │ │ ├── ja4__insta@gtp-iphone.pcap.snap │ │ ├── ja4__insta@http1-with-cookies.pcapng.snap │ │ ├── ja4__insta@http1.pcapng.snap │ │ ├── ja4__insta@http2-with-cookies.pcapng.snap │ │ ├── ja4__insta@ipv6.pcapng.snap │ │ ├── ja4__insta@latest.pcapng.snap │ │ ├── ja4__insta@macos_tcp_flags.pcap.snap │ │ ├── ja4__insta@quic-tls-handshake.pcapng.snap │ │ ├── ja4__insta@quic-with-several-tls-frames.pcapng.snap │ │ ├── ja4__insta@single-packets.pcap.snap │ │ ├── ja4__insta@socks4-https.pcap.snap │ │ ├── ja4__insta@ssh-r.pcap.snap │ │ ├── ja4__insta@ssh-scp-1050.pcap.snap │ │ ├── ja4__insta@ssh.pcapng.snap │ │ ├── ja4__insta@ssh2-malformed.pcap.snap │ │ ├── ja4__insta@ssh2-moloch-crash.pcap.snap │ │ ├── ja4__insta@ssh2.pcapng.snap │ │ ├── ja4__insta@sshv1.pcap.snap │ │ ├── ja4__insta@tcpdump-geneve.pcap.snap │ │ ├── ja4__insta@tls-alpn-h2.pcap.snap │ │ ├── ja4__insta@tls-handshake.pcapng.snap │ │ ├── ja4__insta@tls-non-ascii-alpn.pcapng.snap │ │ ├── ja4__insta@tls-sni.pcapng.snap │ │ ├── ja4__insta@tls12.pcap.snap │ │ ├── ja4__insta@tls3.pcapng.snap │ │ └── ja4__insta@v6.pcap.snap ├── metadata │ ├── context.go │ ├── http2.go │ └── metadata.go ├── proxyserver │ ├── proxyserver.go │ └── proxyserver_test.go ├── reverseproxy │ ├── handler.go │ ├── handler_test.go │ └── header_injector.go └── sync-http2-pkg.sh └── testdata └── gencert.sh /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{json,yml,yaml}] 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | permissions: 11 | # allow creating releases 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: go.mod 21 | 22 | - run: | 23 | go version 24 | go get 25 | 26 | - run: | 27 | make build 28 | make sha256sum 29 | 30 | - working-directory: ./example/echo-server 31 | run: | 32 | make build 33 | make sha256sum 34 | 35 | - name: Create release 36 | env: 37 | GH_TOKEN: ${{ github.token }} 38 | run: | 39 | TAG=$(git describe --tags --abbrev=0 HEAD) 40 | LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ || true) 41 | [[ -n $LAST_TAG ]] && RANGE=$LAST_TAG..HEAD || RANGE=HEAD 42 | RELEASE_NOTES=$(git log "$RANGE" --oneline --decorate) 43 | 44 | gh release create --notes "$RELEASE_NOTES" "$TAG" \ 45 | ./bin/* \ 46 | ./example/echo-server/bin/* 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.vscode 3 | *.crt 4 | *.key 5 | /bin 6 | /example/echo-server/bin 7 | /fingerproxy 8 | .pytest_cache 9 | __pycache__ 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TARGET = fingerproxy 2 | 3 | build: build_darwin_arm64 build_darwin_amd64 \ 4 | build_linux_amd64 build_linux_arm build_linux_arm64 \ 5 | build_windows_amd64 build_windows_arm64 6 | 7 | build_darwin_%: GOOS = darwin 8 | build_linux_%: GOOS = linux 9 | build_windows_%: GOOS = windows 10 | build_windows_%: EXT = .exe 11 | 12 | build_%_amd64: GOARCH = amd64 13 | build_%_arm: GOARCH = arm 14 | build_%_arm64: GOARCH = arm64 15 | 16 | COMMIT = $(shell git rev-parse --short HEAD || true) 17 | TAG = $(shell git describe --tags --abbrev=0 HEAD 2>/dev/null || true) 18 | BINDIR = bin 19 | BINPATH = $(BINDIR)/$(TARGET)_$(GOOS)_$(GOARCH)$(EXT) 20 | 21 | build_%: 22 | GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(BINPATH) \ 23 | -ldflags "-X main.buildCommit=$(COMMIT) -X main.buildVersion=$(TAG)" \ 24 | -gcflags "./...=-m" \ 25 | -gcflags "./pkg/http2=" \ 26 | ./cmd 27 | 28 | chmod +x $(BINPATH) 29 | 30 | sha256sum: 31 | cd $(BINDIR) && sha256sum $(TARGET)_* > $(TARGET).sha256sum 32 | 33 | PKG_LIST = $(shell go list ./... | grep -v github.com/wi1dcard/fingerproxy/pkg/http2) 34 | test: 35 | @go test -v $(PKG_LIST) 36 | 37 | benchmark: 38 | @go test -v $(PKG_LIST) -run=NONE -bench=^Benchmark -benchmem -count=3 -cpu=2 39 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Fingerproxy 2 | Copyright 2024 Weizhe Sun 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/wi1dcard/fingerproxy" 4 | 5 | func main() { 6 | fingerproxy.Run() 7 | } 8 | -------------------------------------------------------------------------------- /e2e/memtest/go.mod: -------------------------------------------------------------------------------- 1 | module memtest 2 | 3 | go 1.22.0 4 | -------------------------------------------------------------------------------- /e2e/memtest/go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/e2e/memtest/go.sum -------------------------------------------------------------------------------- /e2e/memtest/helper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | "os/exec" 8 | ) 9 | 10 | func wget(addr string) string { 11 | resp, err := http.Get(addr) 12 | if err != nil { 13 | panic(err) 14 | } 15 | defer resp.Body.Close() 16 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { 17 | panic(resp.Status) 18 | } 19 | body, err := io.ReadAll(resp.Body) 20 | if err != nil { 21 | panic(err) 22 | } 23 | return string(body) 24 | } 25 | 26 | func printOpenedConn() { 27 | out, err := exec.Command( 28 | "bash", 29 | "-c", 30 | "lsof -nP -i TCP@localhost:8443 -sTCP:ESTABLISHED | grep -e '->127.0.0.1:8443' | wc -l", 31 | ).Output() 32 | 33 | if err != nil { 34 | panic(err) 35 | } 36 | log.Printf("lsof opened conns: %s", string(out)) 37 | } 38 | -------------------------------------------------------------------------------- /e2e/memtest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | const ( 15 | fingerproxyAddr = "https://localhost:8443/" 16 | fingerproxyDebugAddr = "http://localhost:9036/mem" 17 | fingerproxyGcAddr = "http://localhost:9036/gc" 18 | 19 | backendListenAddr = "localhost:8000" 20 | 21 | numConcurrentConns = 1000 22 | sleepBetweenConns = 1 * time.Millisecond 23 | ) 24 | 25 | var ( 26 | waitForRequestsToBackend sync.WaitGroup 27 | waitForRequestsServed sync.WaitGroup 28 | 29 | doneProfiling = false 30 | waitForProfiling = sync.NewCond(&sync.Mutex{}) 31 | ) 32 | 33 | func slowBackend() { 34 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 35 | waitForRequestsToBackend.Done() 36 | waitForProfiling.L.Lock() 37 | for !doneProfiling { 38 | waitForProfiling.Wait() 39 | } 40 | waitForProfiling.L.Unlock() 41 | w.WriteHeader(http.StatusOK) 42 | 43 | fmt.Fprint(w, r.Header.Get("X-HTTP2-Fingerprint")) 44 | }) 45 | 46 | server := &http.Server{ 47 | Addr: backendListenAddr, 48 | ReadTimeout: 0, 49 | WriteTimeout: 0, 50 | IdleTimeout: 0, 51 | } 52 | 53 | log.Printf("backend listening on %s", backendListenAddr) 54 | err := server.ListenAndServe() 55 | if err != nil { 56 | panic(err) 57 | } 58 | } 59 | 60 | func main() { 61 | log.SetOutput(os.Stdout) 62 | 63 | log.Printf("pre-check:") 64 | fmt.Println(wget(fingerproxyDebugAddr)) 65 | 66 | go slowBackend() 67 | time.Sleep(1 * time.Second) // wait until http server starts 68 | 69 | waitForRequestsToBackend.Add(numConcurrentConns) 70 | for i := 0; i < numConcurrentConns; i++ { 71 | go request(i) 72 | time.Sleep(sleepBetweenConns) 73 | } 74 | 75 | waitForRequestsToBackend.Wait() 76 | 77 | printOpenedConn() 78 | 79 | log.Printf("opened %d conns:", numConcurrentConns) 80 | fmt.Println(wget(fingerproxyDebugAddr)) 81 | 82 | waitForProfiling.L.Lock() 83 | doneProfiling = true 84 | waitForProfiling.L.Unlock() 85 | 86 | waitForRequestsServed.Add(numConcurrentConns) 87 | waitForProfiling.Broadcast() 88 | waitForRequestsServed.Wait() 89 | 90 | printOpenedConn() 91 | 92 | log.Printf("conns closed:") 93 | fmt.Println(wget(fingerproxyDebugAddr)) 94 | 95 | log.Printf("after gc:") 96 | wget(fingerproxyGcAddr) 97 | fmt.Println(wget(fingerproxyDebugAddr)) 98 | } 99 | 100 | func request(i int) { 101 | transport := &http.Transport{ 102 | TLSClientConfig: &tls.Config{ 103 | InsecureSkipVerify: true, 104 | }, 105 | 106 | // enable http2 107 | ForceAttemptHTTP2: true, 108 | 109 | // disable connection pool 110 | DisableKeepAlives: true, 111 | MaxIdleConns: -1, 112 | } 113 | 114 | req, err := http.NewRequest("GET", fingerproxyAddr, nil) 115 | if err != nil { 116 | panic(err) 117 | } 118 | 119 | resp, err := transport.RoundTrip(req) 120 | if err != nil { 121 | panic(err) 122 | } 123 | defer resp.Body.Close() 124 | 125 | if resp.StatusCode != http.StatusOK { 126 | panic(fmt.Errorf("[%d] response code seems incorrect: %s", i, resp.Status)) 127 | } 128 | 129 | body, err := io.ReadAll(resp.Body) 130 | if err != nil { 131 | panic(err) 132 | } 133 | 134 | if string(body) != "2:0;4:4194304;6:10485760|1073741824|0|a,m,p,s" { 135 | panic(fmt.Errorf("[%d] response body seems incorrect: %s", i, string(body))) 136 | } 137 | 138 | waitForRequestsServed.Done() 139 | } 140 | -------------------------------------------------------------------------------- /e2e/webbrowser/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | selenium = "*" 8 | pytest = "*" 9 | 10 | [dev-packages] 11 | 12 | [requires] 13 | python_version = "3.12" 14 | -------------------------------------------------------------------------------- /e2e/webbrowser/test_chrome.py: -------------------------------------------------------------------------------- 1 | import json 2 | from selenium import webdriver 3 | from selenium.webdriver.common.by import By 4 | 5 | def test_chrome(): 6 | options = webdriver.ChromeOptions() 7 | options.add_argument('--ignore-certificate-errors') 8 | options.add_argument("--headless") 9 | 10 | driver = webdriver.Chrome(options) 11 | print(driver.capabilities['browserVersion']) 12 | 13 | driver.get("https://localhost:8443/anything") 14 | driver.implicitly_wait(0.5) 15 | print(driver.page_source) 16 | 17 | content = driver.find_element(by=By.TAG_NAME, value='pre').text 18 | parsed_json = json.loads(content) 19 | 20 | # chrome version: 136.0.7103.92 21 | assert parsed_json["headers"]["X-Http2-Fingerprint"] == "1:65536;2:0;4:6291456;6:262144|15663105|1:1:0:256|m,a,s,p" 22 | assert parsed_json["headers"]["X-Ja4-Fingerprint"] == "t13d1516h2_8daaf6152771_d8a2da3f94cd" 23 | 24 | driver.quit() 25 | -------------------------------------------------------------------------------- /e2e/webbrowser/test_firefox.py: -------------------------------------------------------------------------------- 1 | import json 2 | from selenium import webdriver 3 | from selenium.webdriver.common.by import By 4 | 5 | def test_firefox(): 6 | options = webdriver.FirefoxOptions() 7 | options.add_argument('--ignore-certificate-errors') 8 | options.add_argument("--headless") 9 | 10 | driver = webdriver.Firefox(options) 11 | print(driver.capabilities['browserVersion']) 12 | 13 | driver.get("view-source:https://localhost:8443/anything") 14 | driver.implicitly_wait(0.5) 15 | print(driver.page_source) 16 | 17 | content = driver.find_element(by=By.TAG_NAME, value='pre').text 18 | parsed_json = json.loads(content) 19 | 20 | # firefox verison: 138.0.1 21 | assert parsed_json["headers"]["X-Http2-Fingerprint"] == "1:65536;2:0;4:131072;5:16384|12517377|3:0:0:22|m,p,a,s" 22 | assert parsed_json["headers"]["X-Ja3-Fingerprint"] == "6f7889b9fb1a62a9577e685c1fcfa919" 23 | assert parsed_json["headers"]["X-Ja4-Fingerprint"] == "t13d1717h2_5b57614c22b0_3cbfd9057e0d" 24 | 25 | driver.quit() 26 | -------------------------------------------------------------------------------- /env.go: -------------------------------------------------------------------------------- 1 | package fingerproxy 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func envWithDefault(key string, defaultVal string) string { 11 | if envVal, ok := os.LookupEnv(key); ok { 12 | return envVal 13 | } 14 | return defaultVal 15 | } 16 | 17 | func envWithDefaultUint(key string, defaultVal uint) uint { 18 | if envVal, ok := os.LookupEnv(key); ok { 19 | if ret, err := strconv.ParseUint(envVal, 10, 0); err == nil { 20 | return uint(ret) 21 | } else { 22 | log.Fatalf("invalid environment variable $%s, expect uint, actual %s: %s", key, envVal, err) 23 | } 24 | } 25 | return defaultVal 26 | } 27 | 28 | func envWithDefaultBool(key string, defaultVal bool) bool { 29 | if envVal, ok := os.LookupEnv(key); ok { 30 | if strings.ToLower(envVal) == "true" { 31 | return true 32 | } else if strings.ToLower(envVal) == "false" { 33 | return false 34 | } 35 | } 36 | return defaultVal 37 | } 38 | -------------------------------------------------------------------------------- /example/echo-server/Makefile: -------------------------------------------------------------------------------- 1 | TARGET = echo-server 2 | 3 | build: build_darwin_arm64 build_darwin_amd64 \ 4 | build_linux_amd64 build_linux_arm build_linux_arm64 \ 5 | build_windows_amd64 build_windows_arm64 6 | 7 | build_darwin_%: GOOS = darwin 8 | build_linux_%: GOOS = linux 9 | build_windows_%: GOOS = windows 10 | build_windows_%: EXT = .exe 11 | 12 | build_%_amd64: GOARCH = amd64 13 | build_%_arm: GOARCH = arm 14 | build_%_arm64: GOARCH = arm64 15 | 16 | BINDIR = bin 17 | BINPATH = $(BINDIR)/$(TARGET)_$(GOOS)_$(GOARCH)$(EXT) 18 | 19 | build_%: 20 | GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(BINPATH) . 21 | 22 | chmod +x $(BINPATH) 23 | 24 | sha256sum: 25 | cd $(BINDIR) && sha256sum $(TARGET)_* > $(TARGET).sha256sum 26 | -------------------------------------------------------------------------------- /example/echo-server/detail.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/dreadl0ck/tlsx" 8 | "github.com/refraction-networking/utls/dicttls" 9 | "github.com/wi1dcard/fingerproxy/pkg/ja4" 10 | ) 11 | 12 | type ja3Detail tlsx.ClientHelloBasic 13 | 14 | type ja4Detail ja4.JA4Fingerprint 15 | 16 | func (j *ja3Detail) MarshalJSON() ([]byte, error) { 17 | data := struct { 18 | ja3Detail 19 | ReadableCipherSuites []string 20 | ReadableAllExtensions []string 21 | ReadableSupportedGroups []string 22 | }{ 23 | ja3Detail: *j, 24 | ReadableCipherSuites: make([]string, len(j.CipherSuites)), 25 | ReadableAllExtensions: make([]string, len(j.AllExtensions)), 26 | ReadableSupportedGroups: make([]string, len(j.SupportedGroups)), 27 | } 28 | 29 | for i, v := range j.CipherSuites { 30 | u := uint16(v) 31 | if name, ok := dicttls.DictCipherSuiteValueIndexed[u]; ok { 32 | data.ReadableCipherSuites[i] = fmt.Sprintf("%s (0x%x)", name, u) 33 | } else { 34 | data.ReadableCipherSuites[i] = fmt.Sprintf("UNKNOWN (0x%x)", u) 35 | } 36 | } 37 | 38 | for i, v := range j.AllExtensions { 39 | if name, ok := dicttls.DictExtTypeValueIndexed[v]; ok { 40 | data.ReadableAllExtensions[i] = fmt.Sprintf("%s (0x%x)", name, v) 41 | } else { 42 | data.ReadableAllExtensions[i] = fmt.Sprintf("unknown (0x%x)", v) 43 | } 44 | } 45 | 46 | for i, v := range j.SupportedGroups { 47 | if name, ok := dicttls.DictSupportedGroupsValueIndexed[v]; ok { 48 | data.ReadableSupportedGroups[i] = fmt.Sprintf("%s (0x%x)", name, v) 49 | } else { 50 | data.ReadableSupportedGroups[i] = fmt.Sprintf("unknown (0x%x)", v) 51 | } 52 | } 53 | 54 | return json.Marshal(data) 55 | } 56 | 57 | func (j *ja4Detail) MarshalJSON() ([]byte, error) { 58 | data := struct { 59 | ja4Detail 60 | ReadableCipherSuites []string 61 | ReadableExtensions []string 62 | ReadableSignatureAlgorithms []string 63 | }{ 64 | ja4Detail: *j, 65 | ReadableCipherSuites: make([]string, len(j.CipherSuites)), 66 | ReadableExtensions: make([]string, len(j.Extensions)), 67 | ReadableSignatureAlgorithms: make([]string, len(j.SignatureAlgorithms)), 68 | } 69 | 70 | for i, v := range j.CipherSuites { 71 | if name, ok := dicttls.DictCipherSuiteValueIndexed[v]; ok { 72 | data.ReadableCipherSuites[i] = fmt.Sprintf("%s (0x%x)", name, v) 73 | } else { 74 | data.ReadableCipherSuites[i] = fmt.Sprintf("UNKNOWN (0x%x)", v) 75 | } 76 | } 77 | 78 | for i, v := range j.Extensions { 79 | if name, ok := dicttls.DictExtTypeValueIndexed[v]; ok { 80 | data.ReadableExtensions[i] = fmt.Sprintf("%s (0x%x)", name, v) 81 | } else { 82 | data.ReadableExtensions[i] = fmt.Sprintf("unknown (0x%x)", v) 83 | } 84 | } 85 | 86 | for i, v := range j.SignatureAlgorithms { 87 | if name, ok := dicttls.DictSignatureAlgorithmValueIndexed[uint8(v)]; ok { 88 | data.ReadableSignatureAlgorithms[i] = fmt.Sprintf("%s (0x%x)", name, v) 89 | } else { 90 | data.ReadableSignatureAlgorithms[i] = fmt.Sprintf("unknown (0x%x)", v) 91 | } 92 | } 93 | 94 | return json.Marshal(data) 95 | } 96 | -------------------------------------------------------------------------------- /example/echo-server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "log" 8 | "net/http" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/wi1dcard/fingerproxy/pkg/debug" 13 | "github.com/wi1dcard/fingerproxy/pkg/proxyserver" 14 | ) 15 | 16 | var ( 17 | flagListenAddr, flagCertFilename, flagKeyFilename *string 18 | 19 | flagBenchmarkControlGroup, flagVerbose, flagQuiet *bool 20 | 21 | tlsConf *tls.Config 22 | 23 | ctx, _ = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 24 | ) 25 | 26 | func main() { 27 | parseFlags() 28 | 29 | setupTLSConfig() 30 | 31 | if *flagBenchmarkControlGroup { 32 | runAsControlGroup() 33 | } else { 34 | run() 35 | } 36 | } 37 | 38 | func parseFlags() { 39 | flagListenAddr = flag.String( 40 | "listen-addr", 41 | "localhost:8443", 42 | "Listening address", 43 | ) 44 | flagCertFilename = flag.String( 45 | "cert-filename", 46 | "tls.crt", 47 | "TLS certificate filename", 48 | ) 49 | flagKeyFilename = flag.String( 50 | "certkey-filename", 51 | "tls.key", 52 | "TLS certificate key file name", 53 | ) 54 | flagBenchmarkControlGroup = flag.Bool( 55 | "benchmark-control-group", 56 | false, 57 | "Start a golang default TLS server as the control group for benchmarking", 58 | ) 59 | flagVerbose = flag.Bool("verbose", false, "Print fingerprint detail in logs, conflict with -quiet") 60 | flagQuiet = flag.Bool("quiet", false, "Do not print fingerprints in logs, conflict with -verbose") 61 | flag.Parse() 62 | 63 | if *flagVerbose && *flagQuiet { 64 | log.Fatal("-verbose and -quiet cannot be specified at the same time") 65 | } 66 | } 67 | 68 | func setupTLSConfig() { 69 | tlsConf = &tls.Config{ 70 | NextProtos: []string{"h2", "http/1.1"}, 71 | } 72 | 73 | if tlsCert, err := tls.LoadX509KeyPair(*flagCertFilename, *flagKeyFilename); err != nil { 74 | log.Fatal(err) 75 | } else { 76 | tlsConf.Certificates = []tls.Certificate{tlsCert} 77 | } 78 | } 79 | 80 | func runAsControlGroup() { 81 | // create golang default https server 82 | server := &http.Server{ 83 | Addr: *flagListenAddr, 84 | Handler: http.HandlerFunc(echoServer), 85 | TLSConfig: tlsConf, 86 | } 87 | go func() { 88 | <-ctx.Done() 89 | server.Shutdown(context.Background()) 90 | }() 91 | 92 | // listen and serve 93 | log.Printf("server (benchmark control group) listening on %s", *flagListenAddr) 94 | err := server.ListenAndServeTLS("", "") 95 | log.Fatal(err) 96 | } 97 | 98 | func run() { 99 | // create proxyserver 100 | server := proxyserver.NewServer(ctx, http.HandlerFunc(echoServer), tlsConf) 101 | 102 | // start debug server if build tag `debug` is specified 103 | debug.StartDebugServer() 104 | 105 | // listen and serve 106 | log.Printf("server listening on %s", *flagListenAddr) 107 | err := server.ListenAndServe(*flagListenAddr) 108 | log.Fatal(err) 109 | } 110 | -------------------------------------------------------------------------------- /example/echo-server/response.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/dreadl0ck/tlsx" 7 | "github.com/wi1dcard/fingerproxy/pkg/ja3" 8 | "github.com/wi1dcard/fingerproxy/pkg/ja4" 9 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 10 | ) 11 | 12 | // echoResponse is the HTTP response struct of this echo server 13 | type echoResponse struct { 14 | Detail *detailResponse `json:"detail,omitempty"` 15 | JA3 string `json:"ja3"` 16 | JA4 string `json:"ja4"` 17 | HTTP2 string `json:"http2"` 18 | 19 | log *log.Logger 20 | } 21 | 22 | type detailResponse struct { 23 | Metadata *metadata.Metadata `json:"metadata"` 24 | UserAgent string `json:"user_agent"` 25 | JA3 *ja3Detail `json:"ja3"` 26 | JA3Raw string `json:"ja3_raw"` 27 | JA4 *ja4Detail `json:"ja4"` 28 | } 29 | 30 | func (r *echoResponse) fingerprintJA3() error { 31 | fp := &tlsx.ClientHelloBasic{} 32 | rd := r.Detail 33 | err := fp.Unmarshal(rd.Metadata.ClientHelloRecord) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | ja3Raw := ja3.Bare(fp) 39 | 40 | rd.JA3 = (*ja3Detail)(fp) 41 | rd.JA3Raw = string(ja3Raw) 42 | r.JA3 = ja3.BareToDigestHex(ja3Raw) 43 | 44 | r.logf("ja3: %s", r.JA3) 45 | return nil 46 | } 47 | 48 | func (r *echoResponse) fingerprintJA4() error { 49 | fp := &ja4.JA4Fingerprint{} 50 | 51 | err := fp.UnmarshalBytes(r.Detail.Metadata.ClientHelloRecord, 't') 52 | if err != nil { 53 | return err 54 | } 55 | 56 | r.Detail.JA4 = (*ja4Detail)(fp) 57 | r.JA4 = fp.String() 58 | 59 | r.logf("ja4: %s", r.JA4) 60 | return nil 61 | } 62 | 63 | func (r *echoResponse) fingerrpintHTTP2() { 64 | protocol := r.Detail.Metadata.ConnectionState.NegotiatedProtocol 65 | if protocol == "h2" { 66 | r.HTTP2 = r.Detail.Metadata.HTTP2Frames.String() 67 | r.logf("http2: %s", r.HTTP2) 68 | } else if *flagVerbose { 69 | r.logf("protocol is %s, skipping HTTP2 fingerprinting", protocol) 70 | } 71 | } 72 | 73 | func (r *echoResponse) logf(format string, args ...any) { 74 | if !*flagQuiet { 75 | r.log.Printf(format, args...) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /example/echo-server/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 11 | ) 12 | 13 | func echoServer(w http.ResponseWriter, req *http.Request) { 14 | // create logger for this request, it outputs logs with client IP and port as prefix 15 | logger := log.New(os.Stdout, fmt.Sprintf("[client %s] ", req.RemoteAddr), log.LstdFlags|log.Lmsgprefix) 16 | 17 | // get metadata from request context 18 | data, ok := metadata.FromContext(req.Context()) 19 | if !ok { 20 | logger.Printf("failed to get context") 21 | http.Error(w, "failed to get context", http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | // prepare response 26 | response := &echoResponse{ 27 | log: logger, 28 | Detail: &detailResponse{ 29 | Metadata: data, 30 | UserAgent: req.UserAgent(), 31 | }, 32 | } 33 | 34 | // calculate and add fingerprints to the response 35 | if err := response.fingerprintJA3(); err != nil { 36 | logger.Printf(err.Error()) 37 | http.Error(w, err.Error(), http.StatusInternalServerError) 38 | return 39 | } 40 | 41 | if err := response.fingerprintJA4(); err != nil { 42 | logger.Printf(err.Error()) 43 | http.Error(w, err.Error(), http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | response.fingerrpintHTTP2() 48 | 49 | // print detail if -verbose is specified in CLI 50 | if *flagVerbose { 51 | detail, _ := json.Marshal(response.Detail) 52 | logger.Printf("detail: %s", detail) 53 | } 54 | 55 | // send HTTP response 56 | switch req.URL.Path { 57 | case "/json": 58 | w.Header().Set("Content-Type", "application/json") 59 | response.Detail = nil 60 | json.NewEncoder(w).Encode(response) 61 | 62 | case "/json/detail": 63 | w.Header().Set("Content-Type", "application/json") 64 | json.NewEncoder(w).Encode(response) 65 | 66 | default: 67 | fmt.Fprintf(w, "User-Agent: %s\n", response.Detail.UserAgent) 68 | fmt.Fprintf(w, "TLS ClientHello Record: %x\n", response.Detail.Metadata.ClientHelloRecord) 69 | fmt.Fprintf(w, "JA3 fingerprint: %s\n", response.JA3) 70 | fmt.Fprintf(w, "JA4 fingerprint: %s\n", response.JA4) 71 | fmt.Fprintf(w, "HTTP2 fingerprint: %s\n", response.HTTP2) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /example/http2-fingerprint-dos-poc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/http/httptrace" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/wi1dcard/fingerproxy" 18 | "golang.org/x/net/http2" 19 | ) 20 | 21 | /* 22 | This program demonstrates how to craft a large HTTP2 fingerprint. 23 | 24 | The HTTP2 fingerprint format suggested by Akamai is: "S[;]|WU|P[,]#|PS[,]", where 25 | all priority frames in HTTP2 request are recorded and shown in the third part. This 26 | gives attackers a chance to manually create a request with many priority frames 27 | and generate a large HTTP2 fingerprint. This program is to reproduce that. 28 | 29 | By design, Fingerproxy will send this large fingerprint through HTTP request headers 30 | to downstream. That might cause the backend server run out of resource while 31 | processing this large header. Therefore, a limit of max number of priority frames is 32 | introduced. With Fingerproxy binary, you can set the limit in CLI flag "-max-h2-priority-frames". 33 | 34 | See below example. 35 | */ 36 | 37 | const numberOfPriorityFrames = 500 38 | const limitPriorityFrames = 20 39 | 40 | var isCI = "false" 41 | 42 | func main() { 43 | // fingerproxy no limit, header is long: 44 | // url := launchFingerproxy() 45 | 46 | // try with the limit: 47 | url := launchFingerproxyWithPriorityFramesLimit() 48 | 49 | // reproducable with other http2 fingerprinting services: 50 | // url := "https://tls.browserleaks.com/http2" 51 | // url := "https://tls.peet.ws/api/clean" 52 | 53 | time.Sleep(1 * time.Second) 54 | resp := sendRequest(url) 55 | log.Print(string(resp)) 56 | 57 | // below is for CI 58 | if isCI == "true" { 59 | err := assertResponse(resp) 60 | if err != nil { 61 | log.Fatal(err) 62 | } else { 63 | log.Print("response asserted") 64 | } 65 | } 66 | } 67 | 68 | func launchFingerproxy() (url string) { 69 | os.Args = []string{os.Args[0], "-listen-addr=localhost:8443", "-forward-url=https://httpbin.org"} 70 | go fingerproxy.Run() 71 | return "https://localhost:8443/headers" 72 | } 73 | 74 | func launchFingerproxyWithPriorityFramesLimit() (url string) { 75 | os.Args = []string{os.Args[0], "-listen-addr=localhost:8443", "-forward-url=https://httpbin.org", "-max-h2-priority-frames", strconv.Itoa(limitPriorityFrames)} 76 | go fingerproxy.Run() 77 | return "https://localhost:8443/headers" 78 | } 79 | 80 | func sendRequest(url string) []byte { 81 | req, _ := http.NewRequest("GET", url, nil) 82 | 83 | trace := &httptrace.ClientTrace{ 84 | GotConn: gotConn, 85 | } 86 | req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) 87 | 88 | c := &http.Client{ 89 | Transport: &http2.Transport{ 90 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 91 | }, 92 | } 93 | 94 | resp, err := c.Do(req) 95 | 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | 100 | if b, err := io.ReadAll(resp.Body); err != nil { 101 | log.Fatal(err) 102 | return nil 103 | } else { 104 | return b 105 | } 106 | } 107 | 108 | func assertResponse(resp []byte) error { 109 | var parsed struct { 110 | Headers struct { 111 | XHTTP2Fingerprint string `json:"X-Http2-Fingerprint"` 112 | } 113 | } 114 | 115 | err := json.Unmarshal(resp, &parsed) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | fp := parsed.Headers.XHTTP2Fingerprint 121 | parts := strings.Split(fp, "|") 122 | if len(parts) != 4 { 123 | return fmt.Errorf("incorrect http2 fingerprint format: %s", fp) 124 | } 125 | 126 | priorities := strings.Split(parts[2], ",") 127 | expect := limitPriorityFrames 128 | got := len(priorities) 129 | if got != expect { 130 | return fmt.Errorf("expect %d priority frames, got %d: %s", expect, got, parts[2]) 131 | } 132 | return nil 133 | } 134 | 135 | func gotConn(info httptrace.GotConnInfo) { 136 | bw := bufio.NewWriter(info.Conn) 137 | br := bufio.NewReader(info.Conn) 138 | fr := http2.NewFramer(bw, br) 139 | for i := 1; i <= numberOfPriorityFrames; i++ { 140 | err := fr.WritePriority(uint32(i), http2.PriorityParam{Weight: 110}) 141 | if err != nil { 142 | log.Fatal(err) 143 | } 144 | } 145 | bw.Flush() 146 | } 147 | -------------------------------------------------------------------------------- /example/ja3-raw/README.md: -------------------------------------------------------------------------------- 1 | # Fingerproxy Example - JA3 Raw 2 | 3 | This example demonstrates passing the JA3 raw result (without final MD5 hashing) to the backend. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | # Generate fake certificates tls.crt and tls.key 9 | openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 \ 10 | -nodes -keyout tls.key -out tls.crt -subj "/CN=localhost" \ 11 | -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" 12 | 13 | # TLS server listens on :8443, forwarding requests to httpbin 14 | go run . -listen-addr :8443 -forward-url https://httpbin.org 15 | 16 | # Then test in another terminal 17 | curl "https://localhost:8443/headers" --insecure 18 | ``` 19 | 20 | Output: 21 | 22 | ```yaml 23 | { 24 | "headers": { 25 | "Accept": "*/*", 26 | "Accept-Encoding": "gzip", 27 | "Host": "httpbin.org", 28 | "User-Agent": "curl/8.6.0", 29 | "X-Amzn-Trace-Id": "Root=1-664c0810-09f1e3a03376e930030b20f7", 30 | "X-Forwarded-Host": "localhost:8443", 31 | "X-Http2-Fingerprint": "3:100;4:10485760;2:0|1048510465|0|m,s,a,p", 32 | "X-Ja3-Fingerprint": "0149f47eabf9a20d0893e2a44e5a6323", 33 | ## HEADER BELOW ## 34 | "X-Ja3-Raw-Fingerprint": "771,4866-4867-4865-49196-49200-159-52393-52392-52394-49195-49199-158-49188-49192-107-49187-49191-103-49162-49172-57-49161-49171-51-157-156-61-60-53-47-255,0-11-10-16-22-23-49-13-43-45-51-21,29-23-30-25-24-256-257-258-259-260,0-1-2", 35 | ## HEADER ABOVE ## 36 | "X-Ja4-Fingerprint": "t13d3112h2_e8f1e7e78f70_6bebaf5329ac" 37 | } 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /example/ja3-raw/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dreadl0ck/tlsx" 7 | "github.com/wi1dcard/fingerproxy" 8 | "github.com/wi1dcard/fingerproxy/pkg/fingerprint" 9 | "github.com/wi1dcard/fingerproxy/pkg/ja3" 10 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 11 | "github.com/wi1dcard/fingerproxy/pkg/reverseproxy" 12 | ) 13 | 14 | func main() { 15 | fingerproxy.GetHeaderInjectors = func() []reverseproxy.HeaderInjector { 16 | i := fingerproxy.DefaultHeaderInjectors() 17 | i = append(i, fingerprint.NewFingerprintHeaderInjector( 18 | "X-JA3-Raw-Fingerprint", 19 | fpJA3Raw, 20 | )) 21 | return i 22 | } 23 | fingerproxy.Run() 24 | } 25 | 26 | func fpJA3Raw(data *metadata.Metadata) (string, error) { 27 | hellobasic := &tlsx.ClientHelloBasic{} 28 | if err := hellobasic.Unmarshal(data.ClientHelloRecord); err != nil { 29 | return "", fmt.Errorf("ja3: %w", err) 30 | } 31 | 32 | fp := string(ja3.Bare(hellobasic)) 33 | 34 | return fp, nil 35 | } 36 | -------------------------------------------------------------------------------- /example/ja3-sorted-extensions/README.md: -------------------------------------------------------------------------------- 1 | # Fingerproxy Example - JA3 Variant with Sorted Extensions 2 | 3 | JA3 is relatively old. The original implementation is outdated in certain use cases. 4 | 5 | For example, Google Chrome has a feature called [TLS ClientHello extension permutation](https://chromestatus.com/feature/5124606246518784). It permutes the set of TLS extensions sent in the ClientHello message, resulting in a different JA3 fingerprint with every new connection from the browser. 6 | 7 | Therefore we can no longer rely on the order of extensions. Sorting is necessary. Here is a very ugly example. It just demonstrates the possibility of extensibility of Fingerproxy. You might want to implement your own variant of JA3 fingerprint. 8 | 9 | ## Usage 10 | 11 | ```bash 12 | # Generate fake certificates tls.crt and tls.key 13 | openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 \ 14 | -nodes -keyout tls.key -out tls.crt -subj "/CN=localhost" \ 15 | -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" 16 | 17 | # TLS server listens on :8443, forwarding requests to httpbin 18 | go run . -listen-addr :8443 -forward-url https://httpbin.org 19 | 20 | # Then test in another terminal 21 | curl "https://localhost:8443/headers" --insecure 22 | ``` 23 | 24 | Output: 25 | 26 | ```yaml 27 | { 28 | "headers": { 29 | "Accept": "*/*", 30 | "Accept-Encoding": "gzip", 31 | "Host": "httpbin.org", 32 | "User-Agent": "curl/8.6.0", 33 | "X-Amzn-Trace-Id": "Root=1-664c0b9c-4f89ce9c411f2cf22acd59bb", 34 | "X-Forwarded-Host": "localhost:8443", 35 | "X-Http2-Fingerprint": "3:100;4:10485760;2:0|1048510465|0|m,s,a,p", 36 | "X-Ja3-Fingerprint": "0149f47eabf9a20d0893e2a44e5a6323", 37 | "X-Ja4-Fingerprint": "t13d3112h2_e8f1e7e78f70_6bebaf5329ac", 38 | ## HERE ## 39 | "X-Sorted-Ja3-Fingerprint": "22441e3edb4a151c17462a438c7a10a5" 40 | } 41 | } 42 | ``` 43 | 44 | Exit chrome and open again, you will see `X-Ja3-Fingerprint` changed but `X-Sorted-Ja3-Fingerprint` didn't. 45 | 46 | ## More Information 47 | 48 | - 49 | - 50 | -------------------------------------------------------------------------------- /example/ja3-sorted-extensions/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "slices" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/dreadl0ck/tlsx" 11 | "github.com/wi1dcard/fingerproxy" 12 | "github.com/wi1dcard/fingerproxy/pkg/fingerprint" 13 | "github.com/wi1dcard/fingerproxy/pkg/ja3" 14 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 15 | "github.com/wi1dcard/fingerproxy/pkg/reverseproxy" 16 | ) 17 | 18 | func main() { 19 | fingerproxy.GetHeaderInjectors = func() []reverseproxy.HeaderInjector { 20 | i := fingerproxy.DefaultHeaderInjectors() 21 | i = append(i, fingerprint.NewFingerprintHeaderInjector( 22 | "X-Sorted-JA3-Fingerprint", 23 | fpSortedJA3, 24 | )) 25 | return i 26 | } 27 | fingerproxy.Run() 28 | } 29 | 30 | func fpSortedJA3(data *metadata.Metadata) (string, error) { 31 | hellobasic := &tlsx.ClientHelloBasic{} 32 | if err := hellobasic.Unmarshal(data.ClientHelloRecord); err != nil { 33 | return "", fmt.Errorf("ja3: %w", err) 34 | } 35 | 36 | fp := string(ja3.Bare(hellobasic)) 37 | 38 | fields := strings.Split(fp, ",") 39 | if len(fields) != 5 { 40 | // here should be impossible 41 | return "", fmt.Errorf("bad ja3 fingerprint") 42 | } 43 | 44 | extensions := strings.Split(fields[2], "-") 45 | if len(extensions) == 0 { 46 | // no tls extension 47 | return ja3.BareToDigestHex([]byte(fp)), nil 48 | } 49 | 50 | // very ugly implementations for demonstration purpose only 51 | slices.SortFunc(extensions, func(x string, y string) int { 52 | var _x, _y int 53 | var err error 54 | if _x, err = strconv.Atoi(x); err != nil { 55 | return 0 56 | } 57 | if _y, err = strconv.Atoi(y); err != nil { 58 | return 0 59 | } 60 | return cmp.Compare(_x, _y) 61 | }) 62 | 63 | fields[2] = strings.Join(extensions, "-") 64 | fp = strings.Join(fields, ",") 65 | 66 | // return fp, nil 67 | return ja3.BareToDigestHex([]byte(fp)), nil 68 | } 69 | -------------------------------------------------------------------------------- /example/my-fingerprint/README.md: -------------------------------------------------------------------------------- 1 | # Fingerproxy Example - My Fingerprint 2 | 3 | This is an example of implementing a customized fingerprint with TLS ClientHello. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | # Generate fake certificates tls.crt and tls.key 9 | openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 \ 10 | -nodes -keyout tls.key -out tls.crt -subj "/CN=localhost" \ 11 | -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" 12 | 13 | # TLS server listens on :8443, forwarding requests to httpbin 14 | go run . -listen-addr :8443 -forward-url https://httpbin.org 15 | 16 | # Then test in another terminal 17 | curl "https://localhost:8443/headers" --insecure 18 | ``` 19 | 20 | Output: 21 | 22 | ```yaml 23 | { 24 | "headers": { 25 | "Accept": "*/*", 26 | "Accept-Encoding": "gzip", 27 | "Host": "httpbin.org", 28 | "User-Agent": "curl/8.6.0", 29 | "X-Amzn-Trace-Id": "Root=1-664c08ef-4efb8ea50d0d59181a2b1565", 30 | "X-Forwarded-Host": "localhost:8443", 31 | "X-Http2-Fingerprint": "3:100;4:10485760;2:0|1048510465|0|m,s,a,p", 32 | "X-Ja3-Fingerprint": "0149f47eabf9a20d0893e2a44e5a6323", 33 | "X-Ja4-Fingerprint": "t13d3112h2_e8f1e7e78f70_6bebaf5329ac", 34 | ## HERE ## 35 | "X-My-Fingerprint": "alpn:h2,http/1.1|supported_versions:772,771" 36 | } 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /example/my-fingerprint/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/wi1dcard/fingerproxy" 9 | "github.com/wi1dcard/fingerproxy/pkg/fingerprint" 10 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 11 | "github.com/wi1dcard/fingerproxy/pkg/reverseproxy" 12 | 13 | utls "github.com/refraction-networking/utls" 14 | ) 15 | 16 | func main() { 17 | fingerproxy.GetHeaderInjectors = func() []reverseproxy.HeaderInjector { 18 | i := fingerproxy.DefaultHeaderInjectors() 19 | i = append(i, fingerprint.NewFingerprintHeaderInjector( 20 | "X-My-Fingerprint", 21 | SimpleFingerprint, 22 | )) 23 | return i 24 | } 25 | fingerproxy.Run() 26 | } 27 | 28 | func SimpleFingerprint(data *metadata.Metadata) (string, error) { 29 | // parses client hello first 30 | chs := &utls.ClientHelloSpec{} 31 | err := chs.FromRaw(data.ClientHelloRecord, true, true) 32 | if err != nil { 33 | return "", fmt.Errorf("simple fingerprint: %w", err) 34 | } 35 | 36 | // prepare fingerprint buffer 37 | var buf strings.Builder 38 | for _, e := range chs.Extensions { 39 | var field string 40 | switch e := e.(type) { 41 | // use ALPN in the fingerprint 42 | case *utls.ALPNExtension: 43 | field = fmt.Sprintf("alpn:%s", joinAnything(e.AlpnProtocols, ",")) 44 | 45 | // use TLS supported version in the fingerprint 46 | case *utls.SupportedVersionsExtension: 47 | sv := []string{} 48 | for _, v := range e.Versions { 49 | if isGREASEUint16(v) { 50 | sv = append(sv, "GREASE") 51 | } else { 52 | sv = append(sv, strconv.Itoa(int(v))) 53 | } 54 | } 55 | field = fmt.Sprintf("supported_versions:%s", joinAnything(sv, ",")) 56 | } 57 | 58 | // case ...: 59 | // use any extension in your fingerprint 60 | // ... 61 | 62 | if field != "" { 63 | if buf.Len() != 0 { 64 | // add separator if needed 65 | buf.WriteString("|") 66 | } 67 | // add field 68 | buf.WriteString(field) 69 | } 70 | } 71 | 72 | // return fingerprint string 73 | return buf.String(), nil 74 | } 75 | 76 | func isGREASEUint16(v uint16) bool { 77 | // First byte is same as second byte 78 | // and lowest nibble is 0xa 79 | return ((v >> 8) == v&0xff) && v&0xf == 0xa 80 | } 81 | 82 | func joinAnything[E any](elems []E, sep string) string { 83 | var s strings.Builder 84 | s.WriteString(fmt.Sprint(elems[0])) 85 | for _, e := range elems[1:] { 86 | s.WriteString(sep) 87 | s.WriteString(fmt.Sprint(e)) 88 | } 89 | return s.String() 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wi1dcard/fingerproxy 2 | 3 | go 1.21.7 4 | 5 | require ( 6 | github.com/dreadl0ck/tlsx v1.0.1-google-gopacket 7 | github.com/google/gopacket v1.1.18 8 | github.com/prometheus/client_golang v1.18.0 9 | github.com/refraction-networking/utls v1.6.0 10 | golang.org/x/net v0.19.0 11 | golang.org/x/term v0.15.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/andybalholm/brotli v1.0.6 // indirect 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 19 | github.com/cloudflare/circl v1.3.7 // indirect 20 | github.com/fsnotify/fsnotify v1.7.0 // indirect 21 | github.com/google/go-cmp v0.6.0 // indirect 22 | github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect 23 | github.com/klauspost/compress v1.17.4 // indirect 24 | github.com/kr/text v0.2.0 // indirect 25 | github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 26 | github.com/onsi/ginkgo/v2 v2.13.2 // indirect 27 | github.com/onsi/gomega v1.30.0 // indirect 28 | github.com/prometheus/client_model v0.5.0 // indirect 29 | github.com/prometheus/common v0.45.0 // indirect 30 | github.com/prometheus/procfs v0.12.0 // indirect 31 | github.com/quic-go/quic-go v0.40.1 // indirect 32 | golang.org/x/crypto v0.17.0 // indirect 33 | golang.org/x/sys v0.15.0 // indirect 34 | golang.org/x/text v0.14.0 // indirect 35 | golang.org/x/tools v0.16.1 // indirect 36 | google.golang.org/protobuf v1.31.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /pkg/README.md: -------------------------------------------------------------------------------- 1 | # Use Fingerproxy as a Library 2 | 3 | For the documentation, refer to [godoc](https://pkg.go.dev/github.com/wi1dcard/fingerproxy/pkg). 4 | 5 | There are some vendored packages: 6 | 7 | - Package `http2` is a fork of standard http2 package in [`x/net`](https://github.com/golang/net/tree/master/http2). Follow and sync upstream whenever you want using [./sync-http2-pkg.sh](./sync-http2-pkg.sh). 8 | - Package `ja3` is cloned from . See [./ja3/sync.sh](./ja3/sync.sh) for more info. 9 | 10 | If you want to use them, please import from the origin. 11 | -------------------------------------------------------------------------------- /pkg/certwatcher/certwatcher.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Kubernetes Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package certwatcher 18 | 19 | import ( 20 | "context" 21 | "crypto/tls" 22 | "log" 23 | "sync" 24 | 25 | "github.com/fsnotify/fsnotify" 26 | ) 27 | 28 | var ( 29 | VerboseLogs bool 30 | Logger *log.Logger 31 | ) 32 | 33 | func logf(format string, args ...any) { 34 | if Logger != nil { 35 | Logger.Printf(format, args...) 36 | } else { 37 | log.Printf(format, args...) 38 | } 39 | } 40 | 41 | func vlogf(format string, args ...any) { 42 | if VerboseLogs { 43 | logf(format, args...) 44 | } 45 | } 46 | 47 | // CertWatcher watches certificate and key files for changes. When either file 48 | // changes, it reads and parses both and calls an optional callback with the new 49 | // certificate. 50 | type CertWatcher struct { 51 | sync.RWMutex 52 | 53 | currentCert *tls.Certificate 54 | watcher *fsnotify.Watcher 55 | 56 | certPath string 57 | keyPath string 58 | } 59 | 60 | // New returns a new CertWatcher watching the given certificate and key. 61 | func New(certPath, keyPath string) (*CertWatcher, error) { 62 | var err error 63 | 64 | cw := &CertWatcher{ 65 | certPath: certPath, 66 | keyPath: keyPath, 67 | } 68 | 69 | // Initial read of certificate and key. 70 | if err := cw.ReadCertificate(); err != nil { 71 | return nil, err 72 | } 73 | 74 | cw.watcher, err = fsnotify.NewWatcher() 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return cw, nil 80 | } 81 | 82 | // GetCertificate fetches the currently loaded certificate, which may be nil. 83 | func (cw *CertWatcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { 84 | cw.RLock() 85 | defer cw.RUnlock() 86 | return cw.currentCert, nil 87 | } 88 | 89 | // Start starts the watch on the certificate and key files. 90 | func (cw *CertWatcher) Start(ctx context.Context) error { 91 | files := []string{cw.certPath, cw.keyPath} 92 | 93 | for _, f := range files { 94 | if err := cw.watcher.Add(f); err != nil { 95 | logf("error watching file: %s", err) 96 | return err 97 | } 98 | } 99 | 100 | go cw.Watch() 101 | 102 | // Block until the context is done. 103 | <-ctx.Done() 104 | 105 | return cw.watcher.Close() 106 | } 107 | 108 | // Watch reads events from the watcher's channel and reacts to changes. 109 | func (cw *CertWatcher) Watch() { 110 | for { 111 | select { 112 | case event, ok := <-cw.watcher.Events: 113 | // Channel is closed. 114 | if !ok { 115 | return 116 | } 117 | 118 | cw.handleEvent(event) 119 | 120 | case err, ok := <-cw.watcher.Errors: 121 | // Channel is closed. 122 | if !ok { 123 | return 124 | } 125 | 126 | logf("certificate watch error: %s", err) 127 | } 128 | } 129 | } 130 | 131 | // ReadCertificate reads the certificate and key files from disk, parses them, 132 | // and updates the current certificate on the watcher. If a callback is set, it 133 | // is invoked with the new certificate. 134 | func (cw *CertWatcher) ReadCertificate() error { 135 | cert, err := tls.LoadX509KeyPair(cw.certPath, cw.keyPath) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | cw.Lock() 141 | cw.currentCert = &cert 142 | cw.Unlock() 143 | 144 | vlogf("updated current TLS certificate") 145 | 146 | return nil 147 | } 148 | 149 | func (cw *CertWatcher) handleEvent(event fsnotify.Event) { 150 | // Only care about events which may modify the contents of the file. 151 | if !(isWrite(event) || isRemove(event) || isCreate(event)) { 152 | return 153 | } 154 | 155 | vlogf("certificate event: %s", event) 156 | 157 | // If the file was removed, re-add the watch. 158 | if isRemove(event) { 159 | if err := cw.watcher.Add(event.Name); err != nil { 160 | logf("error re-watching file: %s", err) 161 | } 162 | } 163 | 164 | if err := cw.ReadCertificate(); err != nil { 165 | logf("error re-reading certificate: %s", err) 166 | } 167 | } 168 | 169 | func isWrite(event fsnotify.Event) bool { 170 | return event.Op&fsnotify.Write == fsnotify.Write 171 | } 172 | 173 | func isCreate(event fsnotify.Event) bool { 174 | return event.Op&fsnotify.Create == fsnotify.Create 175 | } 176 | 177 | func isRemove(event fsnotify.Event) bool { 178 | return event.Op&fsnotify.Remove == fsnotify.Remove 179 | } 180 | -------------------------------------------------------------------------------- /pkg/debug/debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | // Package debug is a debug server. It is enabled only 4 | // if the `debug` tag is used when building fingerproxy. 5 | package debug 6 | 7 | import ( 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "net/http/pprof" 12 | "runtime" 13 | "runtime/debug" 14 | ) 15 | 16 | const listenAddr = "localhost:9036" 17 | 18 | // Start the debug server when build with build tag `debug`. 19 | func StartDebugServer() { 20 | mux := http.NewServeMux() 21 | 22 | mux.HandleFunc("/gc", func(w http.ResponseWriter, r *http.Request) { 23 | debug.FreeOSMemory() 24 | w.WriteHeader(http.StatusNoContent) 25 | }) 26 | 27 | mux.HandleFunc("/mem", func(w http.ResponseWriter, r *http.Request) { 28 | var memStats runtime.MemStats 29 | runtime.ReadMemStats(&memStats) 30 | 31 | fmt.Fprintf(w, "Heap size: %.2f MiB\n", float64(memStats.HeapInuse)/1024/1024) 32 | fmt.Fprintf(w, "Stack size: %.2f MiB\n", float64(memStats.StackInuse)/1024/1024) 33 | fmt.Fprintf(w, "Num of goroutines: %d\n", runtime.NumGoroutine()) 34 | }) 35 | 36 | mux.HandleFunc("/debug/pprof/", pprof.Index) 37 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 38 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 39 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 40 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) 41 | 42 | log.Printf("!!! DEBUG SERVER LISTENING ON %s !!!", listenAddr) 43 | go http.ListenAndServe(listenAddr, mux) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/debug/debug_stub.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | 3 | package debug 4 | 5 | // Do nothing when build without build tag `debug`. 6 | func StartDebugServer() {} 7 | -------------------------------------------------------------------------------- /pkg/fingerprint/fingerprint.go: -------------------------------------------------------------------------------- 1 | // Package `fingerprint` reads `metadata` and calculate the JA3, JA4, 2 | // HTTP2 fingerprints, etc. 3 | // 4 | // It also implements `header_injector` interface from package `reverseproxy`, 5 | // which allows passing fingerprints to the backend through the forwarding 6 | // request headers. 7 | package fingerprint 8 | 9 | import ( 10 | "fmt" 11 | "log" 12 | 13 | "github.com/dreadl0ck/tlsx" 14 | "github.com/wi1dcard/fingerproxy/pkg/ja3" 15 | "github.com/wi1dcard/fingerproxy/pkg/ja4" 16 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 17 | ) 18 | 19 | var ( 20 | VerboseLogs bool 21 | Logger *log.Logger 22 | ) 23 | 24 | func vlogf(format string, args ...any) { 25 | if VerboseLogs { 26 | if Logger != nil { 27 | Logger.Printf(format, args...) 28 | } else { 29 | log.Printf(format, args...) 30 | } 31 | } 32 | } 33 | 34 | // JA4Fingerprint is a FingerprintFunc 35 | func JA4Fingerprint(data *metadata.Metadata) (string, error) { 36 | fp := &ja4.JA4Fingerprint{} 37 | err := fp.UnmarshalBytes(data.ClientHelloRecord, 't') // TODO: identify connection protocol 38 | if err != nil { 39 | return "", fmt.Errorf("ja4: %w", err) 40 | } 41 | 42 | vlogf("ja4: %s", fp) 43 | return fp.String(), nil 44 | } 45 | 46 | // JA3Fingerprint is a FingerprintFunc 47 | func JA3Fingerprint(data *metadata.Metadata) (string, error) { 48 | hellobasic := &tlsx.ClientHelloBasic{} 49 | if err := hellobasic.Unmarshal(data.ClientHelloRecord); err != nil { 50 | return "", fmt.Errorf("ja3: %w", err) 51 | } 52 | 53 | fp := ja3.DigestHex(hellobasic) 54 | vlogf("ja3: %s", fp) 55 | return fp, nil 56 | } 57 | 58 | type HTTP2FingerprintParam struct { 59 | MaxPriorityFrames uint 60 | } 61 | 62 | // HTTP2Fingerprint is a FingerprintFunc, it creates Akamai HTTP2 fingerprints 63 | // as the suggested format: S[;]|WU|P[,]#|PS[,] 64 | func (p *HTTP2FingerprintParam) HTTP2Fingerprint(data *metadata.Metadata) (string, error) { 65 | if data.ConnectionState.NegotiatedProtocol == "h2" { 66 | fp := data.HTTP2Frames.Marshal(p.MaxPriorityFrames) 67 | vlogf("http2 fingerprint: %s", fp) 68 | return fp, nil 69 | } 70 | 71 | vlogf("%s connection, skipping HTTP2 fingerprinting", data.ConnectionState.NegotiatedProtocol) 72 | return "", nil 73 | } 74 | -------------------------------------------------------------------------------- /pkg/fingerprint/header_injector.go: -------------------------------------------------------------------------------- 1 | package fingerprint 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promauto" 10 | "github.com/wi1dcard/fingerproxy/pkg/metadata" 11 | ) 12 | 13 | const defaultMetricsPrefix = "fingerproxy" 14 | 15 | var ( 16 | fingerprintDurationMetric *prometheus.HistogramVec 17 | ) 18 | 19 | type FingerprintFunc func(*metadata.Metadata) (string, error) 20 | 21 | // FingerprintHeaderInjector implements reverseproxy.HeaderInjector 22 | type FingerprintHeaderInjector struct { 23 | HeaderName string 24 | FingerprintFunc FingerprintFunc 25 | FingerprintDurationSucceedMetric prometheus.Observer 26 | FingerprintDurationErrorMetric prometheus.Observer 27 | } 28 | 29 | func NewFingerprintHeaderInjector(headerName string, fingerprintFunc FingerprintFunc) *FingerprintHeaderInjector { 30 | i := &FingerprintHeaderInjector{ 31 | HeaderName: headerName, 32 | FingerprintFunc: fingerprintFunc, 33 | } 34 | 35 | if fingerprintDurationMetric != nil { 36 | i.FingerprintDurationSucceedMetric = fingerprintDurationMetric.WithLabelValues("1", headerName) 37 | i.FingerprintDurationErrorMetric = fingerprintDurationMetric.WithLabelValues("0", headerName) 38 | } 39 | 40 | return i 41 | } 42 | 43 | func RegisterDurationMetric(registry *prometheus.Registry, buckets []float64, prefix string) { 44 | pm := promauto.With(registry) 45 | 46 | if prefix == "" { 47 | prefix = defaultMetricsPrefix 48 | } 49 | 50 | fingerprintDurationMetric = pm.NewHistogramVec(prometheus.HistogramOpts{ 51 | Namespace: prefix, 52 | Name: "fingerprint_duration_seconds", 53 | Buckets: buckets, 54 | Help: "The duration of fingerprinting requests in seconds", 55 | }, []string{"ok", "header_name"}) 56 | } 57 | 58 | func (i *FingerprintHeaderInjector) GetHeaderName() string { 59 | return i.HeaderName 60 | } 61 | 62 | func (i *FingerprintHeaderInjector) GetHeaderValue(req *http.Request) (string, error) { 63 | data, ok := metadata.FromContext(req.Context()) 64 | if !ok { 65 | return "", fmt.Errorf("failed to get context") 66 | } 67 | 68 | start := time.Now() 69 | fp, err := i.FingerprintFunc(data) 70 | duration := time.Since(start) 71 | vlogf("fingerprint duration: %s", duration) 72 | 73 | if err == nil { 74 | if i.FingerprintDurationSucceedMetric != nil { 75 | i.FingerprintDurationSucceedMetric.Observe(duration.Seconds()) 76 | } 77 | } else { 78 | if i.FingerprintDurationErrorMetric != nil { 79 | i.FingerprintDurationErrorMetric.Observe(duration.Seconds()) 80 | } 81 | } 82 | 83 | return fp, err 84 | } 85 | -------------------------------------------------------------------------------- /pkg/hack/channel_listener.go: -------------------------------------------------------------------------------- 1 | package hack 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | func NewChannelListener(ctx context.Context) *ChannelListener { 10 | ln := &ChannelListener{ 11 | channel: make(chan net.Conn), 12 | } 13 | ln.context, ln.stop = context.WithCancel(ctx) 14 | return ln 15 | } 16 | 17 | type ChannelListener struct { 18 | channel chan net.Conn 19 | context context.Context 20 | stop context.CancelFunc 21 | } 22 | 23 | func (ln *ChannelListener) SendToChannel(conn net.Conn) { 24 | ln.channel <- conn 25 | } 26 | 27 | func (ln *ChannelListener) Accept() (net.Conn, error) { 28 | select { 29 | case <-ln.context.Done(): 30 | return nil, ln.context.Err() 31 | case conn, ok := <-ln.channel: 32 | if ok { 33 | return conn, nil 34 | } else { 35 | return nil, fmt.Errorf("channel listener: internal channel closed") 36 | } 37 | } 38 | } 39 | 40 | func (ln *ChannelListener) Close() error { 41 | ln.stop() 42 | return nil 43 | } 44 | 45 | func (ln *ChannelListener) Addr() net.Addr { return nil } 46 | -------------------------------------------------------------------------------- /pkg/hack/hajack_clienthello_conn.go: -------------------------------------------------------------------------------- 1 | // Package hack includes wraps and hacks of Go net stack. 2 | package hack 3 | 4 | import ( 5 | "bytes" 6 | "crypto/tls" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "time" 11 | ) 12 | 13 | const ( 14 | recordTypeHandshake = 0x16 15 | recordHeaderLen = 5 16 | ) 17 | 18 | var ( 19 | ErrIncompleteClientHello = errors.New("incomplete client hello") 20 | ) 21 | 22 | type HijackClientHelloConn struct { 23 | // internal tls.Conn 24 | tlsConn net.Conn 25 | 26 | // client hello stored in buf 27 | buf bytes.Buffer 28 | 29 | // expected length of the TLS client hello record 30 | expectedLen uint16 31 | 32 | // verbose log func 33 | VerboseLogFunc func(string, ...any) 34 | } 35 | 36 | func NewHijackClientHelloConn(conn net.Conn) *HijackClientHelloConn { 37 | return &HijackClientHelloConn{ 38 | tlsConn: conn, 39 | } 40 | } 41 | 42 | func (c *HijackClientHelloConn) Read(b []byte) (int, error) { 43 | n, err := c.tlsConn.Read(b) 44 | if err == nil { 45 | if c.hasCompleteClientHello() { 46 | c.vlogf("got %d bytes, but client hello is already mature, skipping hijack", n) 47 | } else { 48 | c.hijackClientHello(b[:n]) 49 | } 50 | } 51 | return n, err 52 | } 53 | 54 | func (c *HijackClientHelloConn) hasCompleteClientHello() bool { 55 | bufLen := c.buf.Len() 56 | if bufLen == 0 || c.expectedLen == 0 { 57 | return false 58 | } 59 | if bufLen < int(c.expectedLen) { 60 | return false 61 | } 62 | if bufLen > int(c.expectedLen) { 63 | // if buffer content is longer than we need, 64 | // cut it to expected len 65 | c.buf.Truncate(int(c.expectedLen)) 66 | c.vlogf("truncated buffer from %d to %d bytes", bufLen, c.expectedLen) 67 | } 68 | return true 69 | } 70 | 71 | func (c *HijackClientHelloConn) hijackClientHello(b []byte) { 72 | c.buf.Write(b) 73 | c.vlogf("wrote %d bytes, total %d bytes", len(b), c.buf.Len()) 74 | 75 | // ignores the error which should be impossible 76 | _ = c.tryParseClientHello() 77 | } 78 | 79 | func (c *HijackClientHelloConn) tryParseClientHello() error { 80 | if c.hasCompleteClientHello() { 81 | c.vlogf("client hello is mature, skipping parse") 82 | return nil 83 | } 84 | 85 | bufBytes := c.buf.Bytes() 86 | bufLen := c.buf.Len() 87 | if bufLen < 5 { 88 | c.vlogf("buffer too short (%d bytes), skipping parse", bufLen) 89 | return ErrIncompleteClientHello 90 | } 91 | 92 | recType := bufBytes[0] 93 | if recType != recordTypeHandshake { 94 | return fmt.Errorf("tls record type 0x%x is not a handshake", recType) 95 | } 96 | 97 | vers := uint16(bufBytes[1])<<8 | uint16(bufBytes[2]) 98 | if vers < tls.VersionSSL30 || vers > tls.VersionTLS13 { 99 | return fmt.Errorf("unknown tls version: 0x%x", vers) 100 | } 101 | 102 | handshakeLen := uint16(bufBytes[3])<<8 | uint16(bufBytes[4]) 103 | c.expectedLen = recordHeaderLen + handshakeLen 104 | 105 | // call hasCompleteClientHello to truncate the buffer if possible 106 | if c.hasCompleteClientHello() { 107 | c.vlogf("client hello is mature after got record length") 108 | return nil 109 | } else { 110 | return ErrIncompleteClientHello 111 | } 112 | } 113 | 114 | func (c *HijackClientHelloConn) GetClientHello() ([]byte, error) { 115 | if err := c.tryParseClientHello(); err != nil { 116 | return nil, err 117 | } 118 | return c.buf.Bytes(), nil 119 | } 120 | 121 | func (c *HijackClientHelloConn) vlogf(format string, args ...any) { 122 | if c.VerboseLogFunc != nil { 123 | c.VerboseLogFunc(format, args...) 124 | } 125 | } 126 | 127 | /* 128 | implement net.Conn begin ... 129 | */ 130 | 131 | func (c *HijackClientHelloConn) Write(b []byte) (n int, err error) { return c.tlsConn.Write(b) } 132 | func (c *HijackClientHelloConn) Close() error { return c.tlsConn.Close() } 133 | func (c *HijackClientHelloConn) LocalAddr() net.Addr { return c.tlsConn.LocalAddr() } 134 | func (c *HijackClientHelloConn) RemoteAddr() net.Addr { return c.tlsConn.RemoteAddr() } 135 | func (c *HijackClientHelloConn) SetDeadline(t time.Time) error { return c.tlsConn.SetDeadline(t) } 136 | func (c *HijackClientHelloConn) SetReadDeadline(t time.Time) error { 137 | return c.tlsConn.SetReadDeadline(t) 138 | } 139 | func (c *HijackClientHelloConn) SetWriteDeadline(t time.Time) error { 140 | return c.tlsConn.SetWriteDeadline(t) 141 | } 142 | 143 | /* 144 | ... implement net.Conn end 145 | */ 146 | -------------------------------------------------------------------------------- /pkg/hack/tls_clienthello_conn.go: -------------------------------------------------------------------------------- 1 | package hack 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "net" 7 | "time" 8 | ) 9 | 10 | type TLSClientHelloConn struct { 11 | Conn *tls.Conn 12 | ClientHelloRecord []byte 13 | Done context.CancelFunc 14 | } 15 | 16 | func (c *TLSClientHelloConn) Read(b []byte) (n int, err error) { return c.Conn.Read(b) } 17 | func (c *TLSClientHelloConn) Write(b []byte) (n int, err error) { return c.Conn.Write(b) } 18 | func (c *TLSClientHelloConn) Close() error { 19 | c.Done() 20 | return c.Conn.Close() 21 | } 22 | func (c *TLSClientHelloConn) LocalAddr() net.Addr { return c.Conn.LocalAddr() } 23 | func (c *TLSClientHelloConn) RemoteAddr() net.Addr { return c.Conn.RemoteAddr() } 24 | func (c *TLSClientHelloConn) SetDeadline(t time.Time) error { return c.Conn.SetDeadline(t) } 25 | func (c *TLSClientHelloConn) SetReadDeadline(t time.Time) error { return c.Conn.SetReadDeadline(t) } 26 | func (c *TLSClientHelloConn) SetWriteDeadline(t time.Time) error { return c.Conn.SetWriteDeadline(t) } 27 | -------------------------------------------------------------------------------- /pkg/http2/.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | h2i/h2i 3 | -------------------------------------------------------------------------------- /pkg/http2/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2009 The Go Authors. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google LLC nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /pkg/http2/ascii.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "strings" 8 | 9 | // The HTTP protocols are defined in terms of ASCII, not Unicode. This file 10 | // contains helper functions which may use Unicode-aware functions which would 11 | // otherwise be unsafe and could introduce vulnerabilities if used improperly. 12 | 13 | // asciiEqualFold is strings.EqualFold, ASCII only. It reports whether s and t 14 | // are equal, ASCII-case-insensitively. 15 | func asciiEqualFold(s, t string) bool { 16 | if len(s) != len(t) { 17 | return false 18 | } 19 | for i := 0; i < len(s); i++ { 20 | if lower(s[i]) != lower(t[i]) { 21 | return false 22 | } 23 | } 24 | return true 25 | } 26 | 27 | // lower returns the ASCII lowercase version of b. 28 | func lower(b byte) byte { 29 | if 'A' <= b && b <= 'Z' { 30 | return b + ('a' - 'A') 31 | } 32 | return b 33 | } 34 | 35 | // isASCIIPrint returns whether s is ASCII and printable according to 36 | // https://tools.ietf.org/html/rfc20#section-4.2. 37 | func isASCIIPrint(s string) bool { 38 | for i := 0; i < len(s); i++ { 39 | if s[i] < ' ' || s[i] > '~' { 40 | return false 41 | } 42 | } 43 | return true 44 | } 45 | 46 | // asciiToLower returns the lowercase version of s if s is ASCII and printable, 47 | // and whether or not it was. 48 | func asciiToLower(s string) (lower string, ok bool) { 49 | if !isASCIIPrint(s) { 50 | return "", false 51 | } 52 | return strings.ToLower(s), true 53 | } 54 | -------------------------------------------------------------------------------- /pkg/http2/ascii_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "testing" 8 | 9 | func TestASCIIEqualFold(t *testing.T) { 10 | var tests = []struct { 11 | name string 12 | a, b string 13 | want bool 14 | }{ 15 | { 16 | name: "empty", 17 | want: true, 18 | }, 19 | { 20 | name: "simple match", 21 | a: "CHUNKED", 22 | b: "chunked", 23 | want: true, 24 | }, 25 | { 26 | name: "same string", 27 | a: "chunked", 28 | b: "chunked", 29 | want: true, 30 | }, 31 | { 32 | name: "Unicode Kelvin symbol", 33 | a: "chunKed", // This "K" is 'KELVIN SIGN' (\u212A) 34 | b: "chunked", 35 | want: false, 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | if got := asciiEqualFold(tt.a, tt.b); got != tt.want { 41 | t.Errorf("AsciiEqualFold(%q,%q): got %v want %v", tt.a, tt.b, got, tt.want) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestIsASCIIPrint(t *testing.T) { 48 | var tests = []struct { 49 | name string 50 | in string 51 | want bool 52 | }{ 53 | { 54 | name: "empty", 55 | want: true, 56 | }, 57 | { 58 | name: "ASCII low", 59 | in: "This is a space: ' '", 60 | want: true, 61 | }, 62 | { 63 | name: "ASCII high", 64 | in: "This is a tilde: '~'", 65 | want: true, 66 | }, 67 | { 68 | name: "ASCII low non-print", 69 | in: "This is a unit separator: \x1F", 70 | want: false, 71 | }, 72 | { 73 | name: "Ascii high non-print", 74 | in: "This is a Delete: \x7F", 75 | want: false, 76 | }, 77 | { 78 | name: "Unicode letter", 79 | in: "Today it's 280K outside: it's freezing!", // This "K" is 'KELVIN SIGN' (\u212A) 80 | want: false, 81 | }, 82 | { 83 | name: "Unicode emoji", 84 | in: "Gophers like 🧀", 85 | want: false, 86 | }, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | if got := isASCIIPrint(tt.in); got != tt.want { 91 | t.Errorf("IsASCIIPrint(%q): got %v want %v", tt.in, got, tt.want) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/http2/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "math" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | // http2Config is a package-internal version of net/http.HTTP2Config. 14 | // 15 | // http.HTTP2Config was added in Go 1.24. 16 | // When running with a version of net/http that includes HTTP2Config, 17 | // we merge the configuration with the fields in Transport or Server 18 | // to produce an http2Config. 19 | // 20 | // Zero valued fields in http2Config are interpreted as in the 21 | // net/http.HTTPConfig documentation. 22 | // 23 | // Precedence order for reconciling configurations is: 24 | // 25 | // - Use the net/http.{Server,Transport}.HTTP2Config value, when non-zero. 26 | // - Otherwise use the http2.{Server.Transport} value. 27 | // - If the resulting value is zero or out of range, use a default. 28 | type http2Config struct { 29 | MaxConcurrentStreams uint32 30 | MaxDecoderHeaderTableSize uint32 31 | MaxEncoderHeaderTableSize uint32 32 | MaxReadFrameSize uint32 33 | MaxUploadBufferPerConnection int32 34 | MaxUploadBufferPerStream int32 35 | SendPingTimeout time.Duration 36 | PingTimeout time.Duration 37 | WriteByteTimeout time.Duration 38 | PermitProhibitedCipherSuites bool 39 | CountError func(errType string) 40 | } 41 | 42 | // configFromServer merges configuration settings from 43 | // net/http.Server.HTTP2Config and http2.Server. 44 | func configFromServer(h1 *http.Server, h2 *Server) http2Config { 45 | conf := http2Config{ 46 | MaxConcurrentStreams: h2.MaxConcurrentStreams, 47 | MaxEncoderHeaderTableSize: h2.MaxEncoderHeaderTableSize, 48 | MaxDecoderHeaderTableSize: h2.MaxDecoderHeaderTableSize, 49 | MaxReadFrameSize: h2.MaxReadFrameSize, 50 | MaxUploadBufferPerConnection: h2.MaxUploadBufferPerConnection, 51 | MaxUploadBufferPerStream: h2.MaxUploadBufferPerStream, 52 | SendPingTimeout: h2.ReadIdleTimeout, 53 | PingTimeout: h2.PingTimeout, 54 | WriteByteTimeout: h2.WriteByteTimeout, 55 | PermitProhibitedCipherSuites: h2.PermitProhibitedCipherSuites, 56 | CountError: h2.CountError, 57 | } 58 | fillNetHTTPServerConfig(&conf, h1) 59 | setConfigDefaults(&conf, true) 60 | return conf 61 | } 62 | 63 | // configFromServer merges configuration settings from h2 and h2.t1.HTTP2 64 | // (the net/http Transport). 65 | func configFromTransport(h2 *Transport) http2Config { 66 | conf := http2Config{ 67 | MaxEncoderHeaderTableSize: h2.MaxEncoderHeaderTableSize, 68 | MaxDecoderHeaderTableSize: h2.MaxDecoderHeaderTableSize, 69 | MaxReadFrameSize: h2.MaxReadFrameSize, 70 | SendPingTimeout: h2.ReadIdleTimeout, 71 | PingTimeout: h2.PingTimeout, 72 | WriteByteTimeout: h2.WriteByteTimeout, 73 | } 74 | 75 | // Unlike most config fields, where out-of-range values revert to the default, 76 | // Transport.MaxReadFrameSize clips. 77 | if conf.MaxReadFrameSize < minMaxFrameSize { 78 | conf.MaxReadFrameSize = minMaxFrameSize 79 | } else if conf.MaxReadFrameSize > maxFrameSize { 80 | conf.MaxReadFrameSize = maxFrameSize 81 | } 82 | 83 | if h2.t1 != nil { 84 | fillNetHTTPTransportConfig(&conf, h2.t1) 85 | } 86 | setConfigDefaults(&conf, false) 87 | return conf 88 | } 89 | 90 | func setDefault[T ~int | ~int32 | ~uint32 | ~int64](v *T, minval, maxval, defval T) { 91 | if *v < minval || *v > maxval { 92 | *v = defval 93 | } 94 | } 95 | 96 | func setConfigDefaults(conf *http2Config, server bool) { 97 | setDefault(&conf.MaxConcurrentStreams, 1, math.MaxUint32, defaultMaxStreams) 98 | setDefault(&conf.MaxEncoderHeaderTableSize, 1, math.MaxUint32, initialHeaderTableSize) 99 | setDefault(&conf.MaxDecoderHeaderTableSize, 1, math.MaxUint32, initialHeaderTableSize) 100 | if server { 101 | setDefault(&conf.MaxUploadBufferPerConnection, initialWindowSize, math.MaxInt32, 1<<20) 102 | } else { 103 | setDefault(&conf.MaxUploadBufferPerConnection, initialWindowSize, math.MaxInt32, transportDefaultConnFlow) 104 | } 105 | if server { 106 | setDefault(&conf.MaxUploadBufferPerStream, 1, math.MaxInt32, 1<<20) 107 | } else { 108 | setDefault(&conf.MaxUploadBufferPerStream, 1, math.MaxInt32, transportDefaultStreamFlow) 109 | } 110 | setDefault(&conf.MaxReadFrameSize, minMaxFrameSize, maxFrameSize, defaultMaxReadFrameSize) 111 | setDefault(&conf.PingTimeout, 1, math.MaxInt64, 15*time.Second) 112 | } 113 | 114 | // adjustHTTP1MaxHeaderSize converts a limit in bytes on the size of an HTTP/1 header 115 | // to an HTTP/2 MAX_HEADER_LIST_SIZE value. 116 | func adjustHTTP1MaxHeaderSize(n int64) int64 { 117 | // http2's count is in a slightly different unit and includes 32 bytes per pair. 118 | // So, take the net/http.Server value and pad it up a bit, assuming 10 headers. 119 | const perFieldOverhead = 32 // per http2 spec 120 | const typicalHeaders = 10 // conservative 121 | return n + typicalHeaders*perFieldOverhead 122 | } 123 | -------------------------------------------------------------------------------- /pkg/http2/config_go124.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build go1.24 6 | 7 | package http2 8 | 9 | import "net/http" 10 | 11 | // fillNetHTTPServerConfig sets fields in conf from srv.HTTP2. 12 | func fillNetHTTPServerConfig(conf *http2Config, srv *http.Server) { 13 | fillNetHTTPConfig(conf, srv.HTTP2) 14 | } 15 | 16 | // fillNetHTTPServerConfig sets fields in conf from tr.HTTP2. 17 | func fillNetHTTPTransportConfig(conf *http2Config, tr *http.Transport) { 18 | fillNetHTTPConfig(conf, tr.HTTP2) 19 | } 20 | 21 | func fillNetHTTPConfig(conf *http2Config, h2 *http.HTTP2Config) { 22 | if h2 == nil { 23 | return 24 | } 25 | if h2.MaxConcurrentStreams != 0 { 26 | conf.MaxConcurrentStreams = uint32(h2.MaxConcurrentStreams) 27 | } 28 | if h2.MaxEncoderHeaderTableSize != 0 { 29 | conf.MaxEncoderHeaderTableSize = uint32(h2.MaxEncoderHeaderTableSize) 30 | } 31 | if h2.MaxDecoderHeaderTableSize != 0 { 32 | conf.MaxDecoderHeaderTableSize = uint32(h2.MaxDecoderHeaderTableSize) 33 | } 34 | if h2.MaxConcurrentStreams != 0 { 35 | conf.MaxConcurrentStreams = uint32(h2.MaxConcurrentStreams) 36 | } 37 | if h2.MaxReadFrameSize != 0 { 38 | conf.MaxReadFrameSize = uint32(h2.MaxReadFrameSize) 39 | } 40 | if h2.MaxReceiveBufferPerConnection != 0 { 41 | conf.MaxUploadBufferPerConnection = int32(h2.MaxReceiveBufferPerConnection) 42 | } 43 | if h2.MaxReceiveBufferPerStream != 0 { 44 | conf.MaxUploadBufferPerStream = int32(h2.MaxReceiveBufferPerStream) 45 | } 46 | if h2.SendPingTimeout != 0 { 47 | conf.SendPingTimeout = h2.SendPingTimeout 48 | } 49 | if h2.PingTimeout != 0 { 50 | conf.PingTimeout = h2.PingTimeout 51 | } 52 | if h2.WriteByteTimeout != 0 { 53 | conf.WriteByteTimeout = h2.WriteByteTimeout 54 | } 55 | if h2.PermitProhibitedCipherSuites { 56 | conf.PermitProhibitedCipherSuites = true 57 | } 58 | if h2.CountError != nil { 59 | conf.CountError = h2.CountError 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/http2/config_pre_go124.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build !go1.24 6 | 7 | package http2 8 | 9 | import "net/http" 10 | 11 | // Pre-Go 1.24 fallback. 12 | // The Server.HTTP2 and Transport.HTTP2 config fields were added in Go 1.24. 13 | 14 | func fillNetHTTPServerConfig(conf *http2Config, srv *http.Server) {} 15 | 16 | func fillNetHTTPTransportConfig(conf *http2Config, tr *http.Transport) {} 17 | -------------------------------------------------------------------------------- /pkg/http2/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build go1.24 6 | 7 | package http2 8 | 9 | import ( 10 | "net/http" 11 | "testing" 12 | "time" 13 | ) 14 | 15 | func TestConfigServerSettings(t *testing.T) { 16 | config := &http.HTTP2Config{ 17 | MaxConcurrentStreams: 1, 18 | MaxDecoderHeaderTableSize: 1<<20 + 2, 19 | MaxEncoderHeaderTableSize: 1<<20 + 3, 20 | MaxReadFrameSize: 1<<20 + 4, 21 | MaxReceiveBufferPerConnection: 64<<10 + 5, 22 | MaxReceiveBufferPerStream: 64<<10 + 6, 23 | } 24 | const maxHeaderBytes = 4096 + 7 25 | st := newServerTester(t, nil, func(s *http.Server) { 26 | s.MaxHeaderBytes = maxHeaderBytes 27 | s.HTTP2 = config 28 | }) 29 | st.writePreface() 30 | st.writeSettings() 31 | st.wantSettings(map[SettingID]uint32{ 32 | SettingMaxConcurrentStreams: uint32(config.MaxConcurrentStreams), 33 | SettingHeaderTableSize: uint32(config.MaxDecoderHeaderTableSize), 34 | SettingInitialWindowSize: uint32(config.MaxReceiveBufferPerStream), 35 | SettingMaxFrameSize: uint32(config.MaxReadFrameSize), 36 | SettingMaxHeaderListSize: maxHeaderBytes + (32 * 10), 37 | }) 38 | } 39 | 40 | func TestConfigTransportSettings(t *testing.T) { 41 | config := &http.HTTP2Config{ 42 | MaxConcurrentStreams: 1, // ignored by Transport 43 | MaxDecoderHeaderTableSize: 1<<20 + 2, 44 | MaxEncoderHeaderTableSize: 1<<20 + 3, 45 | MaxReadFrameSize: 1<<20 + 4, 46 | MaxReceiveBufferPerConnection: 64<<10 + 5, 47 | MaxReceiveBufferPerStream: 64<<10 + 6, 48 | } 49 | const maxHeaderBytes = 4096 + 7 50 | tc := newTestClientConn(t, func(tr *http.Transport) { 51 | tr.HTTP2 = config 52 | tr.MaxResponseHeaderBytes = maxHeaderBytes 53 | }) 54 | tc.wantSettings(map[SettingID]uint32{ 55 | SettingHeaderTableSize: uint32(config.MaxDecoderHeaderTableSize), 56 | SettingInitialWindowSize: uint32(config.MaxReceiveBufferPerStream), 57 | SettingMaxFrameSize: uint32(config.MaxReadFrameSize), 58 | SettingMaxHeaderListSize: maxHeaderBytes + (32 * 10), 59 | }) 60 | tc.wantWindowUpdate(0, uint32(config.MaxReceiveBufferPerConnection)) 61 | } 62 | 63 | func TestConfigPingTimeoutServer(t *testing.T) { 64 | st := newServerTester(t, func(w http.ResponseWriter, r *http.Request) { 65 | }, func(s *Server) { 66 | s.ReadIdleTimeout = 2 * time.Second 67 | s.PingTimeout = 3 * time.Second 68 | }) 69 | st.greet() 70 | 71 | st.advance(2 * time.Second) 72 | _ = readFrame[*PingFrame](t, st) 73 | st.advance(3 * time.Second) 74 | st.wantClosed() 75 | } 76 | 77 | func TestConfigPingTimeoutTransport(t *testing.T) { 78 | tc := newTestClientConn(t, func(tr *Transport) { 79 | tr.ReadIdleTimeout = 2 * time.Second 80 | tr.PingTimeout = 3 * time.Second 81 | }) 82 | tc.greet() 83 | 84 | req, _ := http.NewRequest("GET", "https://dummy.tld/", nil) 85 | rt := tc.roundTrip(req) 86 | tc.wantFrameType(FrameHeaders) 87 | 88 | tc.advance(2 * time.Second) 89 | tc.wantFrameType(FramePing) 90 | tc.advance(3 * time.Second) 91 | err := rt.err() 92 | if err == nil { 93 | t.Fatalf("expected connection to close") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/http2/databuffer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "sync" 11 | ) 12 | 13 | // Buffer chunks are allocated from a pool to reduce pressure on GC. 14 | // The maximum wasted space per dataBuffer is 2x the largest size class, 15 | // which happens when the dataBuffer has multiple chunks and there is 16 | // one unread byte in both the first and last chunks. We use a few size 17 | // classes to minimize overheads for servers that typically receive very 18 | // small request bodies. 19 | // 20 | // TODO: Benchmark to determine if the pools are necessary. The GC may have 21 | // improved enough that we can instead allocate chunks like this: 22 | // make([]byte, max(16<<10, expectedBytesRemaining)) 23 | var dataChunkPools = [...]sync.Pool{ 24 | {New: func() interface{} { return new([1 << 10]byte) }}, 25 | {New: func() interface{} { return new([2 << 10]byte) }}, 26 | {New: func() interface{} { return new([4 << 10]byte) }}, 27 | {New: func() interface{} { return new([8 << 10]byte) }}, 28 | {New: func() interface{} { return new([16 << 10]byte) }}, 29 | } 30 | 31 | func getDataBufferChunk(size int64) []byte { 32 | switch { 33 | case size <= 1<<10: 34 | return dataChunkPools[0].Get().(*[1 << 10]byte)[:] 35 | case size <= 2<<10: 36 | return dataChunkPools[1].Get().(*[2 << 10]byte)[:] 37 | case size <= 4<<10: 38 | return dataChunkPools[2].Get().(*[4 << 10]byte)[:] 39 | case size <= 8<<10: 40 | return dataChunkPools[3].Get().(*[8 << 10]byte)[:] 41 | default: 42 | return dataChunkPools[4].Get().(*[16 << 10]byte)[:] 43 | } 44 | } 45 | 46 | func putDataBufferChunk(p []byte) { 47 | switch len(p) { 48 | case 1 << 10: 49 | dataChunkPools[0].Put((*[1 << 10]byte)(p)) 50 | case 2 << 10: 51 | dataChunkPools[1].Put((*[2 << 10]byte)(p)) 52 | case 4 << 10: 53 | dataChunkPools[2].Put((*[4 << 10]byte)(p)) 54 | case 8 << 10: 55 | dataChunkPools[3].Put((*[8 << 10]byte)(p)) 56 | case 16 << 10: 57 | dataChunkPools[4].Put((*[16 << 10]byte)(p)) 58 | default: 59 | panic(fmt.Sprintf("unexpected buffer len=%v", len(p))) 60 | } 61 | } 62 | 63 | // dataBuffer is an io.ReadWriter backed by a list of data chunks. 64 | // Each dataBuffer is used to read DATA frames on a single stream. 65 | // The buffer is divided into chunks so the server can limit the 66 | // total memory used by a single connection without limiting the 67 | // request body size on any single stream. 68 | type dataBuffer struct { 69 | chunks [][]byte 70 | r int // next byte to read is chunks[0][r] 71 | w int // next byte to write is chunks[len(chunks)-1][w] 72 | size int // total buffered bytes 73 | expected int64 // we expect at least this many bytes in future Write calls (ignored if <= 0) 74 | } 75 | 76 | var errReadEmpty = errors.New("read from empty dataBuffer") 77 | 78 | // Read copies bytes from the buffer into p. 79 | // It is an error to read when no data is available. 80 | func (b *dataBuffer) Read(p []byte) (int, error) { 81 | if b.size == 0 { 82 | return 0, errReadEmpty 83 | } 84 | var ntotal int 85 | for len(p) > 0 && b.size > 0 { 86 | readFrom := b.bytesFromFirstChunk() 87 | n := copy(p, readFrom) 88 | p = p[n:] 89 | ntotal += n 90 | b.r += n 91 | b.size -= n 92 | // If the first chunk has been consumed, advance to the next chunk. 93 | if b.r == len(b.chunks[0]) { 94 | putDataBufferChunk(b.chunks[0]) 95 | end := len(b.chunks) - 1 96 | copy(b.chunks[:end], b.chunks[1:]) 97 | b.chunks[end] = nil 98 | b.chunks = b.chunks[:end] 99 | b.r = 0 100 | } 101 | } 102 | return ntotal, nil 103 | } 104 | 105 | func (b *dataBuffer) bytesFromFirstChunk() []byte { 106 | if len(b.chunks) == 1 { 107 | return b.chunks[0][b.r:b.w] 108 | } 109 | return b.chunks[0][b.r:] 110 | } 111 | 112 | // Len returns the number of bytes of the unread portion of the buffer. 113 | func (b *dataBuffer) Len() int { 114 | return b.size 115 | } 116 | 117 | // Write appends p to the buffer. 118 | func (b *dataBuffer) Write(p []byte) (int, error) { 119 | ntotal := len(p) 120 | for len(p) > 0 { 121 | // If the last chunk is empty, allocate a new chunk. Try to allocate 122 | // enough to fully copy p plus any additional bytes we expect to 123 | // receive. However, this may allocate less than len(p). 124 | want := int64(len(p)) 125 | if b.expected > want { 126 | want = b.expected 127 | } 128 | chunk := b.lastChunkOrAlloc(want) 129 | n := copy(chunk[b.w:], p) 130 | p = p[n:] 131 | b.w += n 132 | b.size += n 133 | b.expected -= int64(n) 134 | } 135 | return ntotal, nil 136 | } 137 | 138 | func (b *dataBuffer) lastChunkOrAlloc(want int64) []byte { 139 | if len(b.chunks) != 0 { 140 | last := b.chunks[len(b.chunks)-1] 141 | if b.w < len(last) { 142 | return last 143 | } 144 | } 145 | chunk := getDataBufferChunk(want) 146 | b.chunks = append(b.chunks, chunk) 147 | b.w = 0 148 | return chunk 149 | } 150 | -------------------------------------------------------------------------------- /pkg/http2/databuffer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2017 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "reflect" 11 | "testing" 12 | ) 13 | 14 | func fmtDataChunk(chunk []byte) string { 15 | out := "" 16 | var last byte 17 | var count int 18 | for _, c := range chunk { 19 | if c != last { 20 | if count > 0 { 21 | out += fmt.Sprintf(" x %d ", count) 22 | count = 0 23 | } 24 | out += string([]byte{c}) 25 | last = c 26 | } 27 | count++ 28 | } 29 | if count > 0 { 30 | out += fmt.Sprintf(" x %d", count) 31 | } 32 | return out 33 | } 34 | 35 | func fmtDataChunks(chunks [][]byte) string { 36 | var out string 37 | for _, chunk := range chunks { 38 | out += fmt.Sprintf("{%q}", fmtDataChunk(chunk)) 39 | } 40 | return out 41 | } 42 | 43 | func testDataBuffer(t *testing.T, wantBytes []byte, setup func(t *testing.T) *dataBuffer) { 44 | // Run setup, then read the remaining bytes from the dataBuffer and check 45 | // that they match wantBytes. We use different read sizes to check corner 46 | // cases in Read. 47 | for _, readSize := range []int{1, 2, 1 * 1024, 32 * 1024} { 48 | t.Run(fmt.Sprintf("ReadSize=%d", readSize), func(t *testing.T) { 49 | b := setup(t) 50 | buf := make([]byte, readSize) 51 | var gotRead bytes.Buffer 52 | for { 53 | n, err := b.Read(buf) 54 | gotRead.Write(buf[:n]) 55 | if err == errReadEmpty { 56 | break 57 | } 58 | if err != nil { 59 | t.Fatalf("error after %v bytes: %v", gotRead.Len(), err) 60 | } 61 | } 62 | if got, want := gotRead.Bytes(), wantBytes; !bytes.Equal(got, want) { 63 | t.Errorf("FinalRead=%q, want %q", fmtDataChunk(got), fmtDataChunk(want)) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestDataBufferAllocation(t *testing.T) { 70 | writes := [][]byte{ 71 | bytes.Repeat([]byte("a"), 1*1024-1), 72 | []byte("a"), 73 | bytes.Repeat([]byte("b"), 4*1024-1), 74 | []byte("b"), 75 | bytes.Repeat([]byte("c"), 8*1024-1), 76 | []byte("c"), 77 | bytes.Repeat([]byte("d"), 16*1024-1), 78 | []byte("d"), 79 | bytes.Repeat([]byte("e"), 32*1024), 80 | } 81 | var wantRead bytes.Buffer 82 | for _, p := range writes { 83 | wantRead.Write(p) 84 | } 85 | 86 | testDataBuffer(t, wantRead.Bytes(), func(t *testing.T) *dataBuffer { 87 | b := &dataBuffer{} 88 | for _, p := range writes { 89 | if n, err := b.Write(p); n != len(p) || err != nil { 90 | t.Fatalf("Write(%q x %d)=%v,%v want %v,nil", p[:1], len(p), n, err, len(p)) 91 | } 92 | } 93 | want := [][]byte{ 94 | bytes.Repeat([]byte("a"), 1*1024), 95 | bytes.Repeat([]byte("b"), 4*1024), 96 | bytes.Repeat([]byte("c"), 8*1024), 97 | bytes.Repeat([]byte("d"), 16*1024), 98 | bytes.Repeat([]byte("e"), 16*1024), 99 | bytes.Repeat([]byte("e"), 16*1024), 100 | } 101 | if !reflect.DeepEqual(b.chunks, want) { 102 | t.Errorf("dataBuffer.chunks\ngot: %s\nwant: %s", fmtDataChunks(b.chunks), fmtDataChunks(want)) 103 | } 104 | return b 105 | }) 106 | } 107 | 108 | func TestDataBufferAllocationWithExpected(t *testing.T) { 109 | writes := [][]byte{ 110 | bytes.Repeat([]byte("a"), 1*1024), // allocates 16KB 111 | bytes.Repeat([]byte("b"), 14*1024), 112 | bytes.Repeat([]byte("c"), 15*1024), // allocates 16KB more 113 | bytes.Repeat([]byte("d"), 2*1024), 114 | bytes.Repeat([]byte("e"), 1*1024), // overflows 32KB expectation, allocates just 1KB 115 | } 116 | var wantRead bytes.Buffer 117 | for _, p := range writes { 118 | wantRead.Write(p) 119 | } 120 | 121 | testDataBuffer(t, wantRead.Bytes(), func(t *testing.T) *dataBuffer { 122 | b := &dataBuffer{expected: 32 * 1024} 123 | for _, p := range writes { 124 | if n, err := b.Write(p); n != len(p) || err != nil { 125 | t.Fatalf("Write(%q x %d)=%v,%v want %v,nil", p[:1], len(p), n, err, len(p)) 126 | } 127 | } 128 | want := [][]byte{ 129 | append(bytes.Repeat([]byte("a"), 1*1024), append(bytes.Repeat([]byte("b"), 14*1024), bytes.Repeat([]byte("c"), 1*1024)...)...), 130 | append(bytes.Repeat([]byte("c"), 14*1024), bytes.Repeat([]byte("d"), 2*1024)...), 131 | bytes.Repeat([]byte("e"), 1*1024), 132 | } 133 | if !reflect.DeepEqual(b.chunks, want) { 134 | t.Errorf("dataBuffer.chunks\ngot: %s\nwant: %s", fmtDataChunks(b.chunks), fmtDataChunks(want)) 135 | } 136 | return b 137 | }) 138 | } 139 | 140 | func TestDataBufferWriteAfterPartialRead(t *testing.T) { 141 | testDataBuffer(t, []byte("cdxyz"), func(t *testing.T) *dataBuffer { 142 | b := &dataBuffer{} 143 | if n, err := b.Write([]byte("abcd")); n != 4 || err != nil { 144 | t.Fatalf("Write(\"abcd\")=%v,%v want 4,nil", n, err) 145 | } 146 | p := make([]byte, 2) 147 | if n, err := b.Read(p); n != 2 || err != nil || !bytes.Equal(p, []byte("ab")) { 148 | t.Fatalf("Read()=%q,%v,%v want \"ab\",2,nil", p, n, err) 149 | } 150 | if n, err := b.Write([]byte("xyz")); n != 3 || err != nil { 151 | t.Fatalf("Write(\"xyz\")=%v,%v want 3,nil", n, err) 152 | } 153 | return b 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /pkg/http2/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | ) 11 | 12 | // An ErrCode is an unsigned 32-bit error code as defined in the HTTP/2 spec. 13 | type ErrCode uint32 14 | 15 | const ( 16 | ErrCodeNo ErrCode = 0x0 17 | ErrCodeProtocol ErrCode = 0x1 18 | ErrCodeInternal ErrCode = 0x2 19 | ErrCodeFlowControl ErrCode = 0x3 20 | ErrCodeSettingsTimeout ErrCode = 0x4 21 | ErrCodeStreamClosed ErrCode = 0x5 22 | ErrCodeFrameSize ErrCode = 0x6 23 | ErrCodeRefusedStream ErrCode = 0x7 24 | ErrCodeCancel ErrCode = 0x8 25 | ErrCodeCompression ErrCode = 0x9 26 | ErrCodeConnect ErrCode = 0xa 27 | ErrCodeEnhanceYourCalm ErrCode = 0xb 28 | ErrCodeInadequateSecurity ErrCode = 0xc 29 | ErrCodeHTTP11Required ErrCode = 0xd 30 | ) 31 | 32 | var errCodeName = map[ErrCode]string{ 33 | ErrCodeNo: "NO_ERROR", 34 | ErrCodeProtocol: "PROTOCOL_ERROR", 35 | ErrCodeInternal: "INTERNAL_ERROR", 36 | ErrCodeFlowControl: "FLOW_CONTROL_ERROR", 37 | ErrCodeSettingsTimeout: "SETTINGS_TIMEOUT", 38 | ErrCodeStreamClosed: "STREAM_CLOSED", 39 | ErrCodeFrameSize: "FRAME_SIZE_ERROR", 40 | ErrCodeRefusedStream: "REFUSED_STREAM", 41 | ErrCodeCancel: "CANCEL", 42 | ErrCodeCompression: "COMPRESSION_ERROR", 43 | ErrCodeConnect: "CONNECT_ERROR", 44 | ErrCodeEnhanceYourCalm: "ENHANCE_YOUR_CALM", 45 | ErrCodeInadequateSecurity: "INADEQUATE_SECURITY", 46 | ErrCodeHTTP11Required: "HTTP_1_1_REQUIRED", 47 | } 48 | 49 | func (e ErrCode) String() string { 50 | if s, ok := errCodeName[e]; ok { 51 | return s 52 | } 53 | return fmt.Sprintf("unknown error code 0x%x", uint32(e)) 54 | } 55 | 56 | func (e ErrCode) stringToken() string { 57 | if s, ok := errCodeName[e]; ok { 58 | return s 59 | } 60 | return fmt.Sprintf("ERR_UNKNOWN_%d", uint32(e)) 61 | } 62 | 63 | // ConnectionError is an error that results in the termination of the 64 | // entire connection. 65 | type ConnectionError ErrCode 66 | 67 | func (e ConnectionError) Error() string { return fmt.Sprintf("connection error: %s", ErrCode(e)) } 68 | 69 | // StreamError is an error that only affects one stream within an 70 | // HTTP/2 connection. 71 | type StreamError struct { 72 | StreamID uint32 73 | Code ErrCode 74 | Cause error // optional additional detail 75 | } 76 | 77 | // errFromPeer is a sentinel error value for StreamError.Cause to 78 | // indicate that the StreamError was sent from the peer over the wire 79 | // and wasn't locally generated in the Transport. 80 | var errFromPeer = errors.New("received from peer") 81 | 82 | func streamError(id uint32, code ErrCode) StreamError { 83 | return StreamError{StreamID: id, Code: code} 84 | } 85 | 86 | func (e StreamError) Error() string { 87 | if e.Cause != nil { 88 | return fmt.Sprintf("stream error: stream ID %d; %v; %v", e.StreamID, e.Code, e.Cause) 89 | } 90 | return fmt.Sprintf("stream error: stream ID %d; %v", e.StreamID, e.Code) 91 | } 92 | 93 | // 6.9.1 The Flow Control Window 94 | // "If a sender receives a WINDOW_UPDATE that causes a flow control 95 | // window to exceed this maximum it MUST terminate either the stream 96 | // or the connection, as appropriate. For streams, [...]; for the 97 | // connection, a GOAWAY frame with a FLOW_CONTROL_ERROR code." 98 | type goAwayFlowError struct{} 99 | 100 | func (goAwayFlowError) Error() string { return "connection exceeded flow control window size" } 101 | 102 | // connError represents an HTTP/2 ConnectionError error code, along 103 | // with a string (for debugging) explaining why. 104 | // 105 | // Errors of this type are only returned by the frame parser functions 106 | // and converted into ConnectionError(Code), after stashing away 107 | // the Reason into the Framer's errDetail field, accessible via 108 | // the (*Framer).ErrorDetail method. 109 | type connError struct { 110 | Code ErrCode // the ConnectionError error code 111 | Reason string // additional reason 112 | } 113 | 114 | func (e connError) Error() string { 115 | return fmt.Sprintf("http2: connection error: %v: %v", e.Code, e.Reason) 116 | } 117 | 118 | type pseudoHeaderError string 119 | 120 | func (e pseudoHeaderError) Error() string { 121 | return fmt.Sprintf("invalid pseudo-header %q", string(e)) 122 | } 123 | 124 | type duplicatePseudoHeaderError string 125 | 126 | func (e duplicatePseudoHeaderError) Error() string { 127 | return fmt.Sprintf("duplicate pseudo-header %q", string(e)) 128 | } 129 | 130 | type headerFieldNameError string 131 | 132 | func (e headerFieldNameError) Error() string { 133 | return fmt.Sprintf("invalid header field name %q", string(e)) 134 | } 135 | 136 | type headerFieldValueError string 137 | 138 | func (e headerFieldValueError) Error() string { 139 | return fmt.Sprintf("invalid header field value for %q", string(e)) 140 | } 141 | 142 | var ( 143 | errMixPseudoHeaderTypes = errors.New("mix of request and response pseudo headers") 144 | errPseudoAfterRegular = errors.New("pseudo header field after regular") 145 | ) 146 | -------------------------------------------------------------------------------- /pkg/http2/errors_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "testing" 8 | 9 | func TestErrCodeString(t *testing.T) { 10 | tests := []struct { 11 | err ErrCode 12 | want string 13 | }{ 14 | {ErrCodeProtocol, "PROTOCOL_ERROR"}, 15 | {0xd, "HTTP_1_1_REQUIRED"}, 16 | {0xf, "unknown error code 0xf"}, 17 | } 18 | for i, tt := range tests { 19 | got := tt.err.String() 20 | if got != tt.want { 21 | t.Errorf("%d. Error = %q; want %q", i, got, tt.want) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/http2/flow.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Flow control 6 | 7 | package http2 8 | 9 | // inflowMinRefresh is the minimum number of bytes we'll send for a 10 | // flow control window update. 11 | const inflowMinRefresh = 4 << 10 12 | 13 | // inflow accounts for an inbound flow control window. 14 | // It tracks both the latest window sent to the peer (used for enforcement) 15 | // and the accumulated unsent window. 16 | type inflow struct { 17 | avail int32 18 | unsent int32 19 | } 20 | 21 | // init sets the initial window. 22 | func (f *inflow) init(n int32) { 23 | f.avail = n 24 | } 25 | 26 | // add adds n bytes to the window, with a maximum window size of max, 27 | // indicating that the peer can now send us more data. 28 | // For example, the user read from a {Request,Response} body and consumed 29 | // some of the buffered data, so the peer can now send more. 30 | // It returns the number of bytes to send in a WINDOW_UPDATE frame to the peer. 31 | // Window updates are accumulated and sent when the unsent capacity 32 | // is at least inflowMinRefresh or will at least double the peer's available window. 33 | func (f *inflow) add(n int) (connAdd int32) { 34 | if n < 0 { 35 | panic("negative update") 36 | } 37 | unsent := int64(f.unsent) + int64(n) 38 | // "A sender MUST NOT allow a flow-control window to exceed 2^31-1 octets." 39 | // RFC 7540 Section 6.9.1. 40 | const maxWindow = 1<<31 - 1 41 | if unsent+int64(f.avail) > maxWindow { 42 | panic("flow control update exceeds maximum window size") 43 | } 44 | f.unsent = int32(unsent) 45 | if f.unsent < inflowMinRefresh && f.unsent < f.avail { 46 | // If there aren't at least inflowMinRefresh bytes of window to send, 47 | // and this update won't at least double the window, buffer the update for later. 48 | return 0 49 | } 50 | f.avail += f.unsent 51 | f.unsent = 0 52 | return int32(unsent) 53 | } 54 | 55 | // take attempts to take n bytes from the peer's flow control window. 56 | // It reports whether the window has available capacity. 57 | func (f *inflow) take(n uint32) bool { 58 | if n > uint32(f.avail) { 59 | return false 60 | } 61 | f.avail -= int32(n) 62 | return true 63 | } 64 | 65 | // takeInflows attempts to take n bytes from two inflows, 66 | // typically connection-level and stream-level flows. 67 | // It reports whether both windows have available capacity. 68 | func takeInflows(f1, f2 *inflow, n uint32) bool { 69 | if n > uint32(f1.avail) || n > uint32(f2.avail) { 70 | return false 71 | } 72 | f1.avail -= int32(n) 73 | f2.avail -= int32(n) 74 | return true 75 | } 76 | 77 | // outflow is the outbound flow control window's size. 78 | type outflow struct { 79 | _ incomparable 80 | 81 | // n is the number of DATA bytes we're allowed to send. 82 | // An outflow is kept both on a conn and a per-stream. 83 | n int32 84 | 85 | // conn points to the shared connection-level outflow that is 86 | // shared by all streams on that conn. It is nil for the outflow 87 | // that's on the conn directly. 88 | conn *outflow 89 | } 90 | 91 | func (f *outflow) setConnFlow(cf *outflow) { f.conn = cf } 92 | 93 | func (f *outflow) available() int32 { 94 | n := f.n 95 | if f.conn != nil && f.conn.n < n { 96 | n = f.conn.n 97 | } 98 | return n 99 | } 100 | 101 | func (f *outflow) take(n int32) { 102 | if n > f.available() { 103 | panic("internal error: took too much") 104 | } 105 | f.n -= n 106 | if f.conn != nil { 107 | f.conn.n -= n 108 | } 109 | } 110 | 111 | // add adds n bytes (positive or negative) to the flow control window. 112 | // It returns false if the sum would exceed 2^31-1. 113 | func (f *outflow) add(n int32) bool { 114 | sum := f.n + n 115 | if (sum > n) == (f.n > 0) { 116 | f.n = sum 117 | return true 118 | } 119 | return false 120 | } 121 | -------------------------------------------------------------------------------- /pkg/http2/flow_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "testing" 8 | 9 | func TestInFlowTake(t *testing.T) { 10 | var f inflow 11 | f.init(100) 12 | if !f.take(40) { 13 | t.Fatalf("f.take(40) from 100: got false, want true") 14 | } 15 | if !f.take(40) { 16 | t.Fatalf("f.take(40) from 60: got false, want true") 17 | } 18 | if f.take(40) { 19 | t.Fatalf("f.take(40) from 20: got true, want false") 20 | } 21 | if !f.take(20) { 22 | t.Fatalf("f.take(20) from 20: got false, want true") 23 | } 24 | } 25 | 26 | func TestInflowAddSmall(t *testing.T) { 27 | var f inflow 28 | f.init(0) 29 | // Adding even a small amount when there is no flow causes an immediate send. 30 | if got, want := f.add(1), int32(1); got != want { 31 | t.Fatalf("f.add(1) to 1 = %v, want %v", got, want) 32 | } 33 | } 34 | 35 | func TestInflowAdd(t *testing.T) { 36 | var f inflow 37 | f.init(10 * inflowMinRefresh) 38 | if got, want := f.add(inflowMinRefresh-1), int32(0); got != want { 39 | t.Fatalf("f.add(minRefresh - 1) = %v, want %v", got, want) 40 | } 41 | if got, want := f.add(1), int32(inflowMinRefresh); got != want { 42 | t.Fatalf("f.add(minRefresh) = %v, want %v", got, want) 43 | } 44 | } 45 | 46 | func TestTakeInflows(t *testing.T) { 47 | var a, b inflow 48 | a.init(10) 49 | b.init(20) 50 | if !takeInflows(&a, &b, 5) { 51 | t.Fatalf("takeInflows(a, b, 5) from 10, 20: got false, want true") 52 | } 53 | if takeInflows(&a, &b, 6) { 54 | t.Fatalf("takeInflows(a, b, 6) from 5, 15: got true, want false") 55 | } 56 | if !takeInflows(&a, &b, 5) { 57 | t.Fatalf("takeInflows(a, b, 5) from 5, 15: got false, want true") 58 | } 59 | } 60 | 61 | func TestOutFlow(t *testing.T) { 62 | var st outflow 63 | var conn outflow 64 | st.add(3) 65 | conn.add(2) 66 | 67 | if got, want := st.available(), int32(3); got != want { 68 | t.Errorf("available = %d; want %d", got, want) 69 | } 70 | st.setConnFlow(&conn) 71 | if got, want := st.available(), int32(2); got != want { 72 | t.Errorf("after parent setup, available = %d; want %d", got, want) 73 | } 74 | 75 | st.take(2) 76 | if got, want := conn.available(), int32(0); got != want { 77 | t.Errorf("after taking 2, conn = %d; want %d", got, want) 78 | } 79 | if got, want := st.available(), int32(0); got != want { 80 | t.Errorf("after taking 2, stream = %d; want %d", got, want) 81 | } 82 | } 83 | 84 | func TestOutFlowAdd(t *testing.T) { 85 | var f outflow 86 | if !f.add(1) { 87 | t.Fatal("failed to add 1") 88 | } 89 | if !f.add(-1) { 90 | t.Fatal("failed to add -1") 91 | } 92 | if got, want := f.available(), int32(0); got != want { 93 | t.Fatalf("size = %d; want %d", got, want) 94 | } 95 | if !f.add(1<<31 - 1) { 96 | t.Fatal("failed to add 2^31-1") 97 | } 98 | if got, want := f.available(), int32(1<<31-1); got != want { 99 | t.Fatalf("size = %d; want %d", got, want) 100 | } 101 | if f.add(1) { 102 | t.Fatal("adding 1 to max shouldn't be allowed") 103 | } 104 | } 105 | 106 | func TestOutFlowAddOverflow(t *testing.T) { 107 | var f outflow 108 | if !f.add(0) { 109 | t.Fatal("failed to add 0") 110 | } 111 | if !f.add(-1) { 112 | t.Fatal("failed to add -1") 113 | } 114 | if !f.add(0) { 115 | t.Fatal("failed to add 0") 116 | } 117 | if !f.add(1) { 118 | t.Fatal("failed to add 1") 119 | } 120 | if !f.add(1) { 121 | t.Fatal("failed to add 1") 122 | } 123 | if !f.add(0) { 124 | t.Fatal("failed to add 0") 125 | } 126 | if !f.add(-3) { 127 | t.Fatal("failed to add -3") 128 | } 129 | if got, want := f.available(), int32(-2); got != want { 130 | t.Fatalf("size = %d; want %d", got, want) 131 | } 132 | if !f.add(1<<31 - 1) { 133 | t.Fatal("failed to add 2^31-1") 134 | } 135 | if got, want := f.available(), int32(1+-3+(1<<31-1)); got != want { 136 | t.Fatalf("size = %d; want %d", got, want) 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /pkg/http2/gate_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | package http2 5 | 6 | import "context" 7 | 8 | // An gate is a monitor (mutex + condition variable) with one bit of state. 9 | // 10 | // The condition may be either set or unset. 11 | // Lock operations may be unconditional, or wait for the condition to be set. 12 | // Unlock operations record the new state of the condition. 13 | type gate struct { 14 | // When unlocked, exactly one of set or unset contains a value. 15 | // When locked, neither chan contains a value. 16 | set chan struct{} 17 | unset chan struct{} 18 | } 19 | 20 | // newGate returns a new, unlocked gate with the condition unset. 21 | func newGate() gate { 22 | g := newLockedGate() 23 | g.unlock(false) 24 | return g 25 | } 26 | 27 | // newLocked gate returns a new, locked gate. 28 | func newLockedGate() gate { 29 | return gate{ 30 | set: make(chan struct{}, 1), 31 | unset: make(chan struct{}, 1), 32 | } 33 | } 34 | 35 | // lock acquires the gate unconditionally. 36 | // It reports whether the condition is set. 37 | func (g *gate) lock() (set bool) { 38 | select { 39 | case <-g.set: 40 | return true 41 | case <-g.unset: 42 | return false 43 | } 44 | } 45 | 46 | // waitAndLock waits until the condition is set before acquiring the gate. 47 | // If the context expires, waitAndLock returns an error and does not acquire the gate. 48 | func (g *gate) waitAndLock(ctx context.Context) error { 49 | select { 50 | case <-g.set: 51 | return nil 52 | default: 53 | } 54 | select { 55 | case <-g.set: 56 | return nil 57 | case <-ctx.Done(): 58 | return ctx.Err() 59 | } 60 | } 61 | 62 | // lockIfSet acquires the gate if and only if the condition is set. 63 | func (g *gate) lockIfSet() (acquired bool) { 64 | select { 65 | case <-g.set: 66 | return true 67 | default: 68 | return false 69 | } 70 | } 71 | 72 | // unlock sets the condition and releases the gate. 73 | func (g *gate) unlock(set bool) { 74 | if set { 75 | g.set <- struct{}{} 76 | } else { 77 | g.unset <- struct{}{} 78 | } 79 | } 80 | 81 | // unlock sets the condition to the result of f and releases the gate. 82 | // Useful in defers. 83 | func (g *gate) unlockFunc(f func() bool) { 84 | g.unlock(f()) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/http2/gotrack.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Defensive debug-only utility to track that functions run on the 6 | // goroutine that they're supposed to. 7 | 8 | package http2 9 | 10 | import ( 11 | "bytes" 12 | "errors" 13 | "fmt" 14 | "os" 15 | "runtime" 16 | "strconv" 17 | "sync" 18 | ) 19 | 20 | var DebugGoroutines = os.Getenv("DEBUG_HTTP2_GOROUTINES") == "1" 21 | 22 | type goroutineLock uint64 23 | 24 | func newGoroutineLock() goroutineLock { 25 | if !DebugGoroutines { 26 | return 0 27 | } 28 | return goroutineLock(curGoroutineID()) 29 | } 30 | 31 | func (g goroutineLock) check() { 32 | if !DebugGoroutines { 33 | return 34 | } 35 | if curGoroutineID() != uint64(g) { 36 | panic("running on the wrong goroutine") 37 | } 38 | } 39 | 40 | func (g goroutineLock) checkNotOn() { 41 | if !DebugGoroutines { 42 | return 43 | } 44 | if curGoroutineID() == uint64(g) { 45 | panic("running on the wrong goroutine") 46 | } 47 | } 48 | 49 | var goroutineSpace = []byte("goroutine ") 50 | 51 | func curGoroutineID() uint64 { 52 | bp := littleBuf.Get().(*[]byte) 53 | defer littleBuf.Put(bp) 54 | b := *bp 55 | b = b[:runtime.Stack(b, false)] 56 | // Parse the 4707 out of "goroutine 4707 [" 57 | b = bytes.TrimPrefix(b, goroutineSpace) 58 | i := bytes.IndexByte(b, ' ') 59 | if i < 0 { 60 | panic(fmt.Sprintf("No space found in %q", b)) 61 | } 62 | b = b[:i] 63 | n, err := parseUintBytes(b, 10, 64) 64 | if err != nil { 65 | panic(fmt.Sprintf("Failed to parse goroutine ID out of %q: %v", b, err)) 66 | } 67 | return n 68 | } 69 | 70 | var littleBuf = sync.Pool{ 71 | New: func() interface{} { 72 | buf := make([]byte, 64) 73 | return &buf 74 | }, 75 | } 76 | 77 | // parseUintBytes is like strconv.ParseUint, but using a []byte. 78 | func parseUintBytes(s []byte, base int, bitSize int) (n uint64, err error) { 79 | var cutoff, maxVal uint64 80 | 81 | if bitSize == 0 { 82 | bitSize = int(strconv.IntSize) 83 | } 84 | 85 | s0 := s 86 | switch { 87 | case len(s) < 1: 88 | err = strconv.ErrSyntax 89 | goto Error 90 | 91 | case 2 <= base && base <= 36: 92 | // valid base; nothing to do 93 | 94 | case base == 0: 95 | // Look for octal, hex prefix. 96 | switch { 97 | case s[0] == '0' && len(s) > 1 && (s[1] == 'x' || s[1] == 'X'): 98 | base = 16 99 | s = s[2:] 100 | if len(s) < 1 { 101 | err = strconv.ErrSyntax 102 | goto Error 103 | } 104 | case s[0] == '0': 105 | base = 8 106 | default: 107 | base = 10 108 | } 109 | 110 | default: 111 | err = errors.New("invalid base " + strconv.Itoa(base)) 112 | goto Error 113 | } 114 | 115 | n = 0 116 | cutoff = cutoff64(base) 117 | maxVal = 1<= base { 135 | n = 0 136 | err = strconv.ErrSyntax 137 | goto Error 138 | } 139 | 140 | if n >= cutoff { 141 | // n*base overflows 142 | n = 1<<64 - 1 143 | err = strconv.ErrRange 144 | goto Error 145 | } 146 | n *= uint64(base) 147 | 148 | n1 := n + uint64(v) 149 | if n1 < n || n1 > maxVal { 150 | // n+v overflows 151 | n = 1<<64 - 1 152 | err = strconv.ErrRange 153 | goto Error 154 | } 155 | n = n1 156 | } 157 | 158 | return n, nil 159 | 160 | Error: 161 | return n, &strconv.NumError{Func: "ParseUint", Num: string(s0), Err: err} 162 | } 163 | 164 | // Return the first number n such that n*base >= 1<<64. 165 | func cutoff64(base int) uint64 { 166 | if base < 2 { 167 | return 0 168 | } 169 | return (1<<64-1)/uint64(base) + 1 170 | } 171 | -------------------------------------------------------------------------------- /pkg/http2/gotrack_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestGoroutineLock(t *testing.T) { 14 | oldDebug := DebugGoroutines 15 | DebugGoroutines = true 16 | defer func() { DebugGoroutines = oldDebug }() 17 | 18 | g := newGoroutineLock() 19 | g.check() 20 | 21 | sawPanic := make(chan interface{}) 22 | go func() { 23 | defer func() { sawPanic <- recover() }() 24 | g.check() // should panic 25 | }() 26 | e := <-sawPanic 27 | if e == nil { 28 | t.Fatal("did not see panic from check in other goroutine") 29 | } 30 | if !strings.Contains(fmt.Sprint(e), "wrong goroutine") { 31 | t.Errorf("expected on see panic about running on the wrong goroutine; got %v", e) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/http2/h2c/h2c_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package h2c 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net" 14 | "net/http" 15 | "net/http/httptest" 16 | "strings" 17 | "testing" 18 | 19 | "golang.org/x/net/http2" 20 | ) 21 | 22 | func ExampleNewHandler() { 23 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | fmt.Fprint(w, "Hello world") 25 | }) 26 | h2s := &http2.Server{ 27 | // ... 28 | } 29 | h1s := &http.Server{ 30 | Addr: ":8080", 31 | Handler: NewHandler(handler, h2s), 32 | } 33 | log.Fatal(h1s.ListenAndServe()) 34 | } 35 | 36 | func TestContext(t *testing.T) { 37 | baseCtx := context.WithValue(context.Background(), "testkey", "testvalue") 38 | 39 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | if r.ProtoMajor != 2 { 41 | t.Errorf("Request wasn't handled by h2c. Got ProtoMajor=%v", r.ProtoMajor) 42 | } 43 | if r.Context().Value("testkey") != "testvalue" { 44 | t.Errorf("Request doesn't have expected base context: %v", r.Context()) 45 | } 46 | fmt.Fprint(w, "Hello world") 47 | }) 48 | 49 | h2s := &http2.Server{} 50 | h1s := httptest.NewUnstartedServer(NewHandler(handler, h2s)) 51 | h1s.Config.BaseContext = func(_ net.Listener) context.Context { 52 | return baseCtx 53 | } 54 | h1s.Start() 55 | defer h1s.Close() 56 | 57 | client := &http.Client{ 58 | Transport: &http2.Transport{ 59 | AllowHTTP: true, 60 | DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { 61 | return net.Dial(network, addr) 62 | }, 63 | }, 64 | } 65 | 66 | resp, err := client.Get(h1s.URL) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | _, err = io.ReadAll(resp.Body) 71 | if err != nil { 72 | t.Fatal(err) 73 | } 74 | if err := resp.Body.Close(); err != nil { 75 | t.Fatal(err) 76 | } 77 | } 78 | 79 | func TestPropagation(t *testing.T) { 80 | var ( 81 | server *http.Server 82 | // double the limit because http2 will compress header 83 | headerSize = 1 << 11 84 | headerLimit = 1 << 10 85 | ) 86 | 87 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | if r.ProtoMajor != 2 { 89 | t.Errorf("Request wasn't handled by h2c. Got ProtoMajor=%v", r.ProtoMajor) 90 | } 91 | if r.Context().Value(http.ServerContextKey).(*http.Server) != server { 92 | t.Errorf("Request doesn't have expected http server: %v", r.Context()) 93 | } 94 | if len(r.Header.Get("Long-Header")) != headerSize { 95 | t.Errorf("Request doesn't have expected http header length: %v", len(r.Header.Get("Long-Header"))) 96 | } 97 | fmt.Fprint(w, "Hello world") 98 | }) 99 | 100 | h2s := &http2.Server{} 101 | h1s := httptest.NewUnstartedServer(NewHandler(handler, h2s)) 102 | 103 | server = h1s.Config 104 | server.MaxHeaderBytes = headerLimit 105 | server.ConnState = func(conn net.Conn, state http.ConnState) { 106 | t.Logf("server conn state: conn %s -> %s, status changed to %s", conn.RemoteAddr(), conn.LocalAddr(), state) 107 | } 108 | 109 | h1s.Start() 110 | defer h1s.Close() 111 | 112 | client := &http.Client{ 113 | Transport: &http2.Transport{ 114 | AllowHTTP: true, 115 | DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) { 116 | conn, err := net.Dial(network, addr) 117 | if conn != nil { 118 | t.Logf("client dial tls: %s -> %s", conn.RemoteAddr(), conn.LocalAddr()) 119 | } 120 | return conn, err 121 | }, 122 | }, 123 | } 124 | 125 | req, err := http.NewRequest("GET", h1s.URL, nil) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | req.Header.Set("Long-Header", strings.Repeat("A", headerSize)) 131 | 132 | _, err = client.Do(req) 133 | if err == nil { 134 | t.Fatal("expected server err, got nil") 135 | } 136 | } 137 | 138 | func TestMaxBytesHandler(t *testing.T) { 139 | const bodyLimit = 10 140 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | t.Errorf("got request, expected to be blocked by body limit") 142 | }) 143 | 144 | h2s := &http2.Server{} 145 | h1s := httptest.NewUnstartedServer(http.MaxBytesHandler(NewHandler(handler, h2s), bodyLimit)) 146 | h1s.Start() 147 | defer h1s.Close() 148 | 149 | // Wrap the body in a struct{io.Reader} to prevent it being rewound and resent. 150 | body := "0123456789abcdef" 151 | req, err := http.NewRequest("POST", h1s.URL, struct{ io.Reader }{strings.NewReader(body)}) 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | req.Header.Set("Http2-Settings", "") 156 | req.Header.Set("Upgrade", "h2c") 157 | req.Header.Set("Connection", "Upgrade, HTTP2-Settings") 158 | 159 | resp, err := h1s.Client().Do(req) 160 | if err != nil { 161 | t.Fatal(err) 162 | } 163 | defer resp.Body.Close() 164 | _, err = io.ReadAll(resp.Body) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | if got, want := resp.StatusCode, http.StatusInternalServerError; got != want { 169 | t.Errorf("resp.StatusCode = %v, want %v", got, want) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /pkg/http2/h2i/README.md: -------------------------------------------------------------------------------- 1 | # h2i 2 | 3 | **h2i** is an interactive HTTP/2 ("h2") console debugger. Miss the good ol' 4 | days of telnetting to your HTTP/1.n servers? We're bringing you 5 | back. 6 | 7 | Features: 8 | - send raw HTTP/2 frames 9 | - PING 10 | - SETTINGS 11 | - HEADERS 12 | - etc 13 | - type in HTTP/1.n and have it auto-HPACK/frame-ify it for HTTP/2 14 | - pretty print all received HTTP/2 frames from the peer (including HPACK decoding) 15 | - tab completion of commands, options 16 | 17 | Not yet features, but soon: 18 | - unnecessary CONTINUATION frames on short boundaries, to test peer implementations 19 | - request bodies (DATA frames) 20 | - send invalid frames for testing server implementations (supported by underlying Framer) 21 | 22 | Later: 23 | - act like a server 24 | 25 | ## Installation 26 | 27 | ``` 28 | $ go install golang.org/x/net/http2/h2i@latest 29 | $ h2i 30 | ``` 31 | 32 | ## Demo 33 | 34 | ``` 35 | $ h2i 36 | Usage: h2i 37 | 38 | -insecure 39 | Whether to skip TLS cert validation 40 | -nextproto string 41 | Comma-separated list of NPN/ALPN protocol names to negotiate. (default "h2,h2-14") 42 | 43 | $ h2i google.com 44 | Connecting to google.com:443 ... 45 | Connected to 74.125.224.41:443 46 | Negotiated protocol "h2-14" 47 | [FrameHeader SETTINGS len=18] 48 | [MAX_CONCURRENT_STREAMS = 100] 49 | [INITIAL_WINDOW_SIZE = 1048576] 50 | [MAX_FRAME_SIZE = 16384] 51 | [FrameHeader WINDOW_UPDATE len=4] 52 | Window-Increment = 983041 53 | 54 | h2i> PING h2iSayHI 55 | [FrameHeader PING flags=ACK len=8] 56 | Data = "h2iSayHI" 57 | h2i> headers 58 | (as HTTP/1.1)> GET / HTTP/1.1 59 | (as HTTP/1.1)> Host: ip.appspot.com 60 | (as HTTP/1.1)> User-Agent: h2i/brad-n-blake 61 | (as HTTP/1.1)> 62 | Opening Stream-ID 1: 63 | :authority = ip.appspot.com 64 | :method = GET 65 | :path = / 66 | :scheme = https 67 | user-agent = h2i/brad-n-blake 68 | [FrameHeader HEADERS flags=END_HEADERS stream=1 len=77] 69 | :status = "200" 70 | alternate-protocol = "443:quic,p=1" 71 | content-length = "15" 72 | content-type = "text/html" 73 | date = "Fri, 01 May 2015 23:06:56 GMT" 74 | server = "Google Frontend" 75 | [FrameHeader DATA flags=END_STREAM stream=1 len=15] 76 | "173.164.155.78\n" 77 | [FrameHeader PING len=8] 78 | Data = "\x00\x00\x00\x00\x00\x00\x00\x00" 79 | h2i> ping 80 | [FrameHeader PING flags=ACK len=8] 81 | Data = "h2i_ping" 82 | h2i> ping 83 | [FrameHeader PING flags=ACK len=8] 84 | Data = "h2i_ping" 85 | h2i> ping 86 | [FrameHeader GOAWAY len=22] 87 | Last-Stream-ID = 1; Error-Code = PROTOCOL_ERROR (1) 88 | 89 | ReadFrame: EOF 90 | ``` 91 | 92 | ## Status 93 | 94 | Quick few hour hack. So much yet to do. Feel free to file issues for 95 | bugs or wishlist items, but [@bmizerany](https://github.com/bmizerany/) 96 | and I aren't yet accepting pull requests until things settle down. 97 | 98 | -------------------------------------------------------------------------------- /pkg/http2/headermap.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "net/http" 9 | "sync" 10 | ) 11 | 12 | var ( 13 | commonBuildOnce sync.Once 14 | commonLowerHeader map[string]string // Go-Canonical-Case -> lower-case 15 | commonCanonHeader map[string]string // lower-case -> Go-Canonical-Case 16 | ) 17 | 18 | func buildCommonHeaderMapsOnce() { 19 | commonBuildOnce.Do(buildCommonHeaderMaps) 20 | } 21 | 22 | func buildCommonHeaderMaps() { 23 | common := []string{ 24 | "accept", 25 | "accept-charset", 26 | "accept-encoding", 27 | "accept-language", 28 | "accept-ranges", 29 | "age", 30 | "access-control-allow-credentials", 31 | "access-control-allow-headers", 32 | "access-control-allow-methods", 33 | "access-control-allow-origin", 34 | "access-control-expose-headers", 35 | "access-control-max-age", 36 | "access-control-request-headers", 37 | "access-control-request-method", 38 | "allow", 39 | "authorization", 40 | "cache-control", 41 | "content-disposition", 42 | "content-encoding", 43 | "content-language", 44 | "content-length", 45 | "content-location", 46 | "content-range", 47 | "content-type", 48 | "cookie", 49 | "date", 50 | "etag", 51 | "expect", 52 | "expires", 53 | "from", 54 | "host", 55 | "if-match", 56 | "if-modified-since", 57 | "if-none-match", 58 | "if-unmodified-since", 59 | "last-modified", 60 | "link", 61 | "location", 62 | "max-forwards", 63 | "origin", 64 | "proxy-authenticate", 65 | "proxy-authorization", 66 | "range", 67 | "referer", 68 | "refresh", 69 | "retry-after", 70 | "server", 71 | "set-cookie", 72 | "strict-transport-security", 73 | "trailer", 74 | "transfer-encoding", 75 | "user-agent", 76 | "vary", 77 | "via", 78 | "www-authenticate", 79 | "x-forwarded-for", 80 | "x-forwarded-proto", 81 | } 82 | commonLowerHeader = make(map[string]string, len(common)) 83 | commonCanonHeader = make(map[string]string, len(common)) 84 | for _, v := range common { 85 | chk := http.CanonicalHeaderKey(v) 86 | commonLowerHeader[chk] = v 87 | commonCanonHeader[v] = chk 88 | } 89 | } 90 | 91 | func lowerHeader(v string) (lower string, ascii bool) { 92 | buildCommonHeaderMapsOnce() 93 | if s, ok := commonLowerHeader[v]; ok { 94 | return s, true 95 | } 96 | return asciiToLower(v) 97 | } 98 | 99 | func canonicalHeader(v string) string { 100 | buildCommonHeaderMapsOnce() 101 | if s, ok := commonCanonHeader[v]; ok { 102 | return s 103 | } 104 | return http.CanonicalHeaderKey(v) 105 | } 106 | -------------------------------------------------------------------------------- /pkg/http2/hpack/gen.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build ignore 6 | 7 | package main 8 | 9 | import ( 10 | "bytes" 11 | "fmt" 12 | "go/format" 13 | "os" 14 | "sort" 15 | 16 | "golang.org/x/net/http2/hpack" 17 | ) 18 | 19 | // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-07#appendix-B 20 | var staticTableEntries = [...]hpack.HeaderField{ 21 | {Name: ":authority"}, 22 | {Name: ":method", Value: "GET"}, 23 | {Name: ":method", Value: "POST"}, 24 | {Name: ":path", Value: "/"}, 25 | {Name: ":path", Value: "/index.html"}, 26 | {Name: ":scheme", Value: "http"}, 27 | {Name: ":scheme", Value: "https"}, 28 | {Name: ":status", Value: "200"}, 29 | {Name: ":status", Value: "204"}, 30 | {Name: ":status", Value: "206"}, 31 | {Name: ":status", Value: "304"}, 32 | {Name: ":status", Value: "400"}, 33 | {Name: ":status", Value: "404"}, 34 | {Name: ":status", Value: "500"}, 35 | {Name: "accept-charset"}, 36 | {Name: "accept-encoding", Value: "gzip, deflate"}, 37 | {Name: "accept-language"}, 38 | {Name: "accept-ranges"}, 39 | {Name: "accept"}, 40 | {Name: "access-control-allow-origin"}, 41 | {Name: "age"}, 42 | {Name: "allow"}, 43 | {Name: "authorization"}, 44 | {Name: "cache-control"}, 45 | {Name: "content-disposition"}, 46 | {Name: "content-encoding"}, 47 | {Name: "content-language"}, 48 | {Name: "content-length"}, 49 | {Name: "content-location"}, 50 | {Name: "content-range"}, 51 | {Name: "content-type"}, 52 | {Name: "cookie"}, 53 | {Name: "date"}, 54 | {Name: "etag"}, 55 | {Name: "expect"}, 56 | {Name: "expires"}, 57 | {Name: "from"}, 58 | {Name: "host"}, 59 | {Name: "if-match"}, 60 | {Name: "if-modified-since"}, 61 | {Name: "if-none-match"}, 62 | {Name: "if-range"}, 63 | {Name: "if-unmodified-since"}, 64 | {Name: "last-modified"}, 65 | {Name: "link"}, 66 | {Name: "location"}, 67 | {Name: "max-forwards"}, 68 | {Name: "proxy-authenticate"}, 69 | {Name: "proxy-authorization"}, 70 | {Name: "range"}, 71 | {Name: "referer"}, 72 | {Name: "refresh"}, 73 | {Name: "retry-after"}, 74 | {Name: "server"}, 75 | {Name: "set-cookie"}, 76 | {Name: "strict-transport-security"}, 77 | {Name: "transfer-encoding"}, 78 | {Name: "user-agent"}, 79 | {Name: "vary"}, 80 | {Name: "via"}, 81 | {Name: "www-authenticate"}, 82 | } 83 | 84 | type pairNameValue struct { 85 | name, value string 86 | } 87 | 88 | type byNameItem struct { 89 | name string 90 | id uint64 91 | } 92 | 93 | type byNameValueItem struct { 94 | pairNameValue 95 | id uint64 96 | } 97 | 98 | func headerFieldToString(f hpack.HeaderField) string { 99 | return fmt.Sprintf("{Name: \"%s\", Value:\"%s\", Sensitive: %t}", f.Name, f.Value, f.Sensitive) 100 | } 101 | 102 | func pairNameValueToString(v pairNameValue) string { 103 | return fmt.Sprintf("{name: \"%s\", value:\"%s\"}", v.name, v.value) 104 | } 105 | 106 | const header = ` 107 | // go generate gen.go 108 | // Code generated by the command above; DO NOT EDIT. 109 | 110 | package hpack 111 | 112 | var staticTable = &headerFieldTable{ 113 | evictCount: 0, 114 | byName: map[string]uint64{ 115 | ` 116 | 117 | //go:generate go run gen.go 118 | func main() { 119 | var bb bytes.Buffer 120 | fmt.Fprintf(&bb, header) 121 | byName := make(map[string]uint64) 122 | byNameValue := make(map[pairNameValue]uint64) 123 | for index, entry := range staticTableEntries { 124 | id := uint64(index) + 1 125 | byName[entry.Name] = id 126 | byNameValue[pairNameValue{entry.Name, entry.Value}] = id 127 | } 128 | // Sort maps for deterministic generation. 129 | byNameItems := sortByName(byName) 130 | byNameValueItems := sortByNameValue(byNameValue) 131 | 132 | for _, item := range byNameItems { 133 | fmt.Fprintf(&bb, "\"%s\":%d,\n", item.name, item.id) 134 | } 135 | fmt.Fprintf(&bb, "},\n") 136 | fmt.Fprintf(&bb, "byNameValue: map[pairNameValue]uint64{\n") 137 | for _, item := range byNameValueItems { 138 | fmt.Fprintf(&bb, "%s:%d,\n", pairNameValueToString(item.pairNameValue), item.id) 139 | } 140 | fmt.Fprintf(&bb, "},\n") 141 | fmt.Fprintf(&bb, "ents: []HeaderField{\n") 142 | for _, value := range staticTableEntries { 143 | fmt.Fprintf(&bb, "%s,\n", headerFieldToString(value)) 144 | } 145 | fmt.Fprintf(&bb, "},\n") 146 | fmt.Fprintf(&bb, "}\n") 147 | genFile("static_table.go", &bb) 148 | } 149 | 150 | func sortByNameValue(byNameValue map[pairNameValue]uint64) []byNameValueItem { 151 | var byNameValueItems []byNameValueItem 152 | for k, v := range byNameValue { 153 | byNameValueItems = append(byNameValueItems, byNameValueItem{k, v}) 154 | } 155 | sort.Slice(byNameValueItems, func(i, j int) bool { 156 | return byNameValueItems[i].id < byNameValueItems[j].id 157 | }) 158 | return byNameValueItems 159 | } 160 | 161 | func sortByName(byName map[string]uint64) []byNameItem { 162 | var byNameItems []byNameItem 163 | for k, v := range byName { 164 | byNameItems = append(byNameItems, byNameItem{k, v}) 165 | } 166 | sort.Slice(byNameItems, func(i, j int) bool { 167 | return byNameItems[i].id < byNameItems[j].id 168 | }) 169 | return byNameItems 170 | } 171 | 172 | func genFile(name string, buf *bytes.Buffer) { 173 | b, err := format.Source(buf.Bytes()) 174 | if err != nil { 175 | fmt.Fprintln(os.Stderr, err) 176 | os.Exit(1) 177 | } 178 | if err := os.WriteFile(name, b, 0644); err != nil { 179 | fmt.Fprintln(os.Stderr, err) 180 | os.Exit(1) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /pkg/http2/pipe.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "errors" 9 | "io" 10 | "sync" 11 | ) 12 | 13 | // pipe is a goroutine-safe io.Reader/io.Writer pair. It's like 14 | // io.Pipe except there are no PipeReader/PipeWriter halves, and the 15 | // underlying buffer is an interface. (io.Pipe is always unbuffered) 16 | type pipe struct { 17 | mu sync.Mutex 18 | c sync.Cond // c.L lazily initialized to &p.mu 19 | b pipeBuffer // nil when done reading 20 | unread int // bytes unread when done 21 | err error // read error once empty. non-nil means closed. 22 | breakErr error // immediate read error (caller doesn't see rest of b) 23 | donec chan struct{} // closed on error 24 | readFn func() // optional code to run in Read before error 25 | } 26 | 27 | type pipeBuffer interface { 28 | Len() int 29 | io.Writer 30 | io.Reader 31 | } 32 | 33 | // setBuffer initializes the pipe buffer. 34 | // It has no effect if the pipe is already closed. 35 | func (p *pipe) setBuffer(b pipeBuffer) { 36 | p.mu.Lock() 37 | defer p.mu.Unlock() 38 | if p.err != nil || p.breakErr != nil { 39 | return 40 | } 41 | p.b = b 42 | } 43 | 44 | func (p *pipe) Len() int { 45 | p.mu.Lock() 46 | defer p.mu.Unlock() 47 | if p.b == nil { 48 | return p.unread 49 | } 50 | return p.b.Len() 51 | } 52 | 53 | // Read waits until data is available and copies bytes 54 | // from the buffer into p. 55 | func (p *pipe) Read(d []byte) (n int, err error) { 56 | p.mu.Lock() 57 | defer p.mu.Unlock() 58 | if p.c.L == nil { 59 | p.c.L = &p.mu 60 | } 61 | for { 62 | if p.breakErr != nil { 63 | return 0, p.breakErr 64 | } 65 | if p.b != nil && p.b.Len() > 0 { 66 | return p.b.Read(d) 67 | } 68 | if p.err != nil { 69 | if p.readFn != nil { 70 | p.readFn() // e.g. copy trailers 71 | p.readFn = nil // not sticky like p.err 72 | } 73 | p.b = nil 74 | return 0, p.err 75 | } 76 | p.c.Wait() 77 | } 78 | } 79 | 80 | var ( 81 | errClosedPipeWrite = errors.New("write on closed buffer") 82 | errUninitializedPipeWrite = errors.New("write on uninitialized buffer") 83 | ) 84 | 85 | // Write copies bytes from p into the buffer and wakes a reader. 86 | // It is an error to write more data than the buffer can hold. 87 | func (p *pipe) Write(d []byte) (n int, err error) { 88 | p.mu.Lock() 89 | defer p.mu.Unlock() 90 | if p.c.L == nil { 91 | p.c.L = &p.mu 92 | } 93 | defer p.c.Signal() 94 | if p.err != nil || p.breakErr != nil { 95 | return 0, errClosedPipeWrite 96 | } 97 | // pipe.setBuffer is never invoked, leaving the buffer uninitialized. 98 | // We shouldn't try to write to an uninitialized pipe, 99 | // but returning an error is better than panicking. 100 | if p.b == nil { 101 | return 0, errUninitializedPipeWrite 102 | } 103 | return p.b.Write(d) 104 | } 105 | 106 | // CloseWithError causes the next Read (waking up a current blocked 107 | // Read if needed) to return the provided err after all data has been 108 | // read. 109 | // 110 | // The error must be non-nil. 111 | func (p *pipe) CloseWithError(err error) { p.closeWithError(&p.err, err, nil) } 112 | 113 | // BreakWithError causes the next Read (waking up a current blocked 114 | // Read if needed) to return the provided err immediately, without 115 | // waiting for unread data. 116 | func (p *pipe) BreakWithError(err error) { p.closeWithError(&p.breakErr, err, nil) } 117 | 118 | // closeWithErrorAndCode is like CloseWithError but also sets some code to run 119 | // in the caller's goroutine before returning the error. 120 | func (p *pipe) closeWithErrorAndCode(err error, fn func()) { p.closeWithError(&p.err, err, fn) } 121 | 122 | func (p *pipe) closeWithError(dst *error, err error, fn func()) { 123 | if err == nil { 124 | panic("err must be non-nil") 125 | } 126 | p.mu.Lock() 127 | defer p.mu.Unlock() 128 | if p.c.L == nil { 129 | p.c.L = &p.mu 130 | } 131 | defer p.c.Signal() 132 | if *dst != nil { 133 | // Already been done. 134 | return 135 | } 136 | p.readFn = fn 137 | if dst == &p.breakErr { 138 | if p.b != nil { 139 | p.unread += p.b.Len() 140 | } 141 | p.b = nil 142 | } 143 | *dst = err 144 | p.closeDoneLocked() 145 | } 146 | 147 | // requires p.mu be held. 148 | func (p *pipe) closeDoneLocked() { 149 | if p.donec == nil { 150 | return 151 | } 152 | // Close if unclosed. This isn't racy since we always 153 | // hold p.mu while closing. 154 | select { 155 | case <-p.donec: 156 | default: 157 | close(p.donec) 158 | } 159 | } 160 | 161 | // Err returns the error (if any) first set by BreakWithError or CloseWithError. 162 | func (p *pipe) Err() error { 163 | p.mu.Lock() 164 | defer p.mu.Unlock() 165 | if p.breakErr != nil { 166 | return p.breakErr 167 | } 168 | return p.err 169 | } 170 | 171 | // Done returns a channel which is closed if and when this pipe is closed 172 | // with CloseWithError. 173 | func (p *pipe) Done() <-chan struct{} { 174 | p.mu.Lock() 175 | defer p.mu.Unlock() 176 | if p.donec == nil { 177 | p.donec = make(chan struct{}) 178 | if p.err != nil || p.breakErr != nil { 179 | // Already hit an error. 180 | p.closeDoneLocked() 181 | } 182 | } 183 | return p.donec 184 | } 185 | -------------------------------------------------------------------------------- /pkg/http2/pipe_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "io" 11 | "testing" 12 | ) 13 | 14 | func TestPipeClose(t *testing.T) { 15 | var p pipe 16 | p.b = new(bytes.Buffer) 17 | a := errors.New("a") 18 | b := errors.New("b") 19 | p.CloseWithError(a) 20 | p.CloseWithError(b) 21 | _, err := p.Read(make([]byte, 1)) 22 | if err != a { 23 | t.Errorf("err = %v want %v", err, a) 24 | } 25 | } 26 | 27 | func TestPipeDoneChan(t *testing.T) { 28 | var p pipe 29 | done := p.Done() 30 | select { 31 | case <-done: 32 | t.Fatal("done too soon") 33 | default: 34 | } 35 | p.CloseWithError(io.EOF) 36 | select { 37 | case <-done: 38 | default: 39 | t.Fatal("should be done") 40 | } 41 | } 42 | 43 | func TestPipeDoneChan_ErrFirst(t *testing.T) { 44 | var p pipe 45 | p.CloseWithError(io.EOF) 46 | done := p.Done() 47 | select { 48 | case <-done: 49 | default: 50 | t.Fatal("should be done") 51 | } 52 | } 53 | 54 | func TestPipeDoneChan_Break(t *testing.T) { 55 | var p pipe 56 | done := p.Done() 57 | select { 58 | case <-done: 59 | t.Fatal("done too soon") 60 | default: 61 | } 62 | p.BreakWithError(io.EOF) 63 | select { 64 | case <-done: 65 | default: 66 | t.Fatal("should be done") 67 | } 68 | } 69 | 70 | func TestPipeDoneChan_Break_ErrFirst(t *testing.T) { 71 | var p pipe 72 | p.BreakWithError(io.EOF) 73 | done := p.Done() 74 | select { 75 | case <-done: 76 | default: 77 | t.Fatal("should be done") 78 | } 79 | } 80 | 81 | func TestPipeCloseWithError(t *testing.T) { 82 | p := &pipe{b: new(bytes.Buffer)} 83 | const body = "foo" 84 | io.WriteString(p, body) 85 | a := errors.New("test error") 86 | p.CloseWithError(a) 87 | all, err := io.ReadAll(p) 88 | if string(all) != body { 89 | t.Errorf("read bytes = %q; want %q", all, body) 90 | } 91 | if err != a { 92 | t.Logf("read error = %v, %v", err, a) 93 | } 94 | if p.Len() != 0 { 95 | t.Errorf("pipe should have 0 unread bytes") 96 | } 97 | // Read and Write should fail. 98 | if n, err := p.Write([]byte("abc")); err != errClosedPipeWrite || n != 0 { 99 | t.Errorf("Write(abc) after close\ngot %v, %v\nwant 0, %v", n, err, errClosedPipeWrite) 100 | } 101 | if n, err := p.Read(make([]byte, 1)); err == nil || n != 0 { 102 | t.Errorf("Read() after close\ngot %v, nil\nwant 0, %v", n, errClosedPipeWrite) 103 | } 104 | if p.Len() != 0 { 105 | t.Errorf("pipe should have 0 unread bytes") 106 | } 107 | } 108 | 109 | func TestPipeBreakWithError(t *testing.T) { 110 | p := &pipe{b: new(bytes.Buffer)} 111 | io.WriteString(p, "foo") 112 | a := errors.New("test err") 113 | p.BreakWithError(a) 114 | all, err := io.ReadAll(p) 115 | if string(all) != "" { 116 | t.Errorf("read bytes = %q; want empty string", all) 117 | } 118 | if err != a { 119 | t.Logf("read error = %v, %v", err, a) 120 | } 121 | if p.b != nil { 122 | t.Errorf("buffer should be nil after BreakWithError") 123 | } 124 | if p.Len() != 3 { 125 | t.Errorf("pipe should have 3 unread bytes") 126 | } 127 | // Write should fail. 128 | if n, err := p.Write([]byte("abc")); err != errClosedPipeWrite || n != 0 { 129 | t.Errorf("Write(abc) after break\ngot %v, %v\nwant 0, errClosedPipeWrite", n, err) 130 | } 131 | if p.b != nil { 132 | t.Errorf("buffer should be nil after Write") 133 | } 134 | if p.Len() != 3 { 135 | t.Errorf("pipe should have 6 unread bytes") 136 | } 137 | // Read should fail. 138 | if n, err := p.Read(make([]byte, 1)); err == nil || n != 0 { 139 | t.Errorf("Read() after close\ngot %v, nil\nwant 0, not nil", n) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /pkg/http2/timer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | package http2 5 | 6 | import "time" 7 | 8 | // A timer is a time.Timer, as an interface which can be replaced in tests. 9 | type timer = interface { 10 | C() <-chan time.Time 11 | Reset(d time.Duration) bool 12 | Stop() bool 13 | } 14 | 15 | // timeTimer adapts a time.Timer to the timer interface. 16 | type timeTimer struct { 17 | *time.Timer 18 | } 19 | 20 | func (t timeTimer) C() <-chan time.Time { return t.Timer.C } 21 | -------------------------------------------------------------------------------- /pkg/http2/unencrypted.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "crypto/tls" 9 | "errors" 10 | "net" 11 | ) 12 | 13 | const nextProtoUnencryptedHTTP2 = "unencrypted_http2" 14 | 15 | // unencryptedNetConnFromTLSConn retrieves a net.Conn wrapped in a *tls.Conn. 16 | // 17 | // TLSNextProto functions accept a *tls.Conn. 18 | // 19 | // When passing an unencrypted HTTP/2 connection to a TLSNextProto function, 20 | // we pass a *tls.Conn with an underlying net.Conn containing the unencrypted connection. 21 | // To be extra careful about mistakes (accidentally dropping TLS encryption in a place 22 | // where we want it), the tls.Conn contains a net.Conn with an UnencryptedNetConn method 23 | // that returns the actual connection we want to use. 24 | func unencryptedNetConnFromTLSConn(tc *tls.Conn) (net.Conn, error) { 25 | conner, ok := tc.NetConn().(interface { 26 | UnencryptedNetConn() net.Conn 27 | }) 28 | if !ok { 29 | return nil, errors.New("http2: TLS conn unexpectedly found in unencrypted handoff") 30 | } 31 | return conner.UnencryptedNetConn(), nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/http2/writesched_random.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "math" 8 | 9 | // NewRandomWriteScheduler constructs a WriteScheduler that ignores HTTP/2 10 | // priorities. Control frames like SETTINGS and PING are written before DATA 11 | // frames, but if no control frames are queued and multiple streams have queued 12 | // HEADERS or DATA frames, Pop selects a ready stream arbitrarily. 13 | func NewRandomWriteScheduler() WriteScheduler { 14 | return &randomWriteScheduler{sq: make(map[uint32]*writeQueue)} 15 | } 16 | 17 | type randomWriteScheduler struct { 18 | // zero are frames not associated with a specific stream. 19 | zero writeQueue 20 | 21 | // sq contains the stream-specific queues, keyed by stream ID. 22 | // When a stream is idle, closed, or emptied, it's deleted 23 | // from the map. 24 | sq map[uint32]*writeQueue 25 | 26 | // pool of empty queues for reuse. 27 | queuePool writeQueuePool 28 | } 29 | 30 | func (ws *randomWriteScheduler) OpenStream(streamID uint32, options OpenStreamOptions) { 31 | // no-op: idle streams are not tracked 32 | } 33 | 34 | func (ws *randomWriteScheduler) CloseStream(streamID uint32) { 35 | q, ok := ws.sq[streamID] 36 | if !ok { 37 | return 38 | } 39 | delete(ws.sq, streamID) 40 | ws.queuePool.put(q) 41 | } 42 | 43 | func (ws *randomWriteScheduler) AdjustStream(streamID uint32, priority PriorityParam) { 44 | // no-op: priorities are ignored 45 | } 46 | 47 | func (ws *randomWriteScheduler) Push(wr FrameWriteRequest) { 48 | if wr.isControl() { 49 | ws.zero.push(wr) 50 | return 51 | } 52 | id := wr.StreamID() 53 | q, ok := ws.sq[id] 54 | if !ok { 55 | q = ws.queuePool.get() 56 | ws.sq[id] = q 57 | } 58 | q.push(wr) 59 | } 60 | 61 | func (ws *randomWriteScheduler) Pop() (FrameWriteRequest, bool) { 62 | // Control and RST_STREAM frames first. 63 | if !ws.zero.empty() { 64 | return ws.zero.shift(), true 65 | } 66 | // Iterate over all non-idle streams until finding one that can be consumed. 67 | for streamID, q := range ws.sq { 68 | if wr, ok := q.consume(math.MaxInt32); ok { 69 | if q.empty() { 70 | delete(ws.sq, streamID) 71 | ws.queuePool.put(q) 72 | } 73 | return wr, true 74 | } 75 | } 76 | return FrameWriteRequest{}, false 77 | } 78 | -------------------------------------------------------------------------------- /pkg/http2/writesched_random_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import "testing" 8 | 9 | func TestRandomScheduler(t *testing.T) { 10 | ws := NewRandomWriteScheduler() 11 | ws.Push(makeWriteHeadersRequest(3)) 12 | ws.Push(makeWriteHeadersRequest(4)) 13 | ws.Push(makeWriteHeadersRequest(1)) 14 | ws.Push(makeWriteHeadersRequest(2)) 15 | ws.Push(makeWriteNonStreamRequest()) 16 | ws.Push(makeWriteNonStreamRequest()) 17 | ws.Push(makeWriteRSTStream(1)) 18 | 19 | // Pop all frames. Should get the non-stream and RST stream requests first, 20 | // followed by the stream requests in any order. 21 | var order []FrameWriteRequest 22 | for { 23 | wr, ok := ws.Pop() 24 | if !ok { 25 | break 26 | } 27 | order = append(order, wr) 28 | } 29 | t.Logf("got frames: %v", order) 30 | if len(order) != 7 { 31 | t.Fatalf("got %d frames, expected 6", len(order)) 32 | } 33 | if order[0].StreamID() != 0 || order[1].StreamID() != 0 { 34 | t.Fatal("expected non-stream frames first", order[0], order[1]) 35 | } 36 | if _, ok := order[2].write.(StreamError); !ok { 37 | t.Fatal("expected RST stream frames first", order[2]) 38 | } 39 | got := make(map[uint32]bool) 40 | for _, wr := range order[2:] { 41 | got[wr.StreamID()] = true 42 | } 43 | for id := uint32(1); id <= 4; id++ { 44 | if !got[id] { 45 | t.Errorf("frame not found for stream %d", id) 46 | } 47 | } 48 | 49 | // Verify that we clean up maps for empty queues in all cases (golang.org/issue/33812) 50 | const arbitraryStreamID = 123 51 | ws.Push(makeHandlerPanicRST(arbitraryStreamID)) 52 | rws := ws.(*randomWriteScheduler) 53 | if got, want := len(rws.sq), 1; got != want { 54 | t.Fatalf("len of 123 stream = %v; want %v", got, want) 55 | } 56 | _, ok := ws.Pop() 57 | if !ok { 58 | t.Fatal("expected to be able to Pop") 59 | } 60 | if got, want := len(rws.sq), 0; got != want { 61 | t.Fatalf("len of 123 stream = %v; want %v", got, want) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /pkg/http2/writesched_roundrobin.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "fmt" 9 | "math" 10 | ) 11 | 12 | type roundRobinWriteScheduler struct { 13 | // control contains control frames (SETTINGS, PING, etc.). 14 | control writeQueue 15 | 16 | // streams maps stream ID to a queue. 17 | streams map[uint32]*writeQueue 18 | 19 | // stream queues are stored in a circular linked list. 20 | // head is the next stream to write, or nil if there are no streams open. 21 | head *writeQueue 22 | 23 | // pool of empty queues for reuse. 24 | queuePool writeQueuePool 25 | } 26 | 27 | // newRoundRobinWriteScheduler constructs a new write scheduler. 28 | // The round robin scheduler priorizes control frames 29 | // like SETTINGS and PING over DATA frames. 30 | // When there are no control frames to send, it performs a round-robin 31 | // selection from the ready streams. 32 | func newRoundRobinWriteScheduler() WriteScheduler { 33 | ws := &roundRobinWriteScheduler{ 34 | streams: make(map[uint32]*writeQueue), 35 | } 36 | return ws 37 | } 38 | 39 | func (ws *roundRobinWriteScheduler) OpenStream(streamID uint32, options OpenStreamOptions) { 40 | if ws.streams[streamID] != nil { 41 | panic(fmt.Errorf("stream %d already opened", streamID)) 42 | } 43 | q := ws.queuePool.get() 44 | ws.streams[streamID] = q 45 | if ws.head == nil { 46 | ws.head = q 47 | q.next = q 48 | q.prev = q 49 | } else { 50 | // Queues are stored in a ring. 51 | // Insert the new stream before ws.head, putting it at the end of the list. 52 | q.prev = ws.head.prev 53 | q.next = ws.head 54 | q.prev.next = q 55 | q.next.prev = q 56 | } 57 | } 58 | 59 | func (ws *roundRobinWriteScheduler) CloseStream(streamID uint32) { 60 | q := ws.streams[streamID] 61 | if q == nil { 62 | return 63 | } 64 | if q.next == q { 65 | // This was the only open stream. 66 | ws.head = nil 67 | } else { 68 | q.prev.next = q.next 69 | q.next.prev = q.prev 70 | if ws.head == q { 71 | ws.head = q.next 72 | } 73 | } 74 | delete(ws.streams, streamID) 75 | ws.queuePool.put(q) 76 | } 77 | 78 | func (ws *roundRobinWriteScheduler) AdjustStream(streamID uint32, priority PriorityParam) {} 79 | 80 | func (ws *roundRobinWriteScheduler) Push(wr FrameWriteRequest) { 81 | if wr.isControl() { 82 | ws.control.push(wr) 83 | return 84 | } 85 | q := ws.streams[wr.StreamID()] 86 | if q == nil { 87 | // This is a closed stream. 88 | // wr should not be a HEADERS or DATA frame. 89 | // We push the request onto the control queue. 90 | if wr.DataSize() > 0 { 91 | panic("add DATA on non-open stream") 92 | } 93 | ws.control.push(wr) 94 | return 95 | } 96 | q.push(wr) 97 | } 98 | 99 | func (ws *roundRobinWriteScheduler) Pop() (FrameWriteRequest, bool) { 100 | // Control and RST_STREAM frames first. 101 | if !ws.control.empty() { 102 | return ws.control.shift(), true 103 | } 104 | if ws.head == nil { 105 | return FrameWriteRequest{}, false 106 | } 107 | q := ws.head 108 | for { 109 | if wr, ok := q.consume(math.MaxInt32); ok { 110 | ws.head = q.next 111 | return wr, true 112 | } 113 | q = q.next 114 | if q == ws.head { 115 | break 116 | } 117 | } 118 | return FrameWriteRequest{}, false 119 | } 120 | -------------------------------------------------------------------------------- /pkg/http2/writesched_roundrobin_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package http2 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestRoundRobinScheduler(t *testing.T) { 13 | const maxFrameSize = 16 14 | sc := &serverConn{maxFrameSize: maxFrameSize} 15 | ws := newRoundRobinWriteScheduler() 16 | streams := make([]*stream, 4) 17 | for i := range streams { 18 | streamID := uint32(i) + 1 19 | streams[i] = &stream{ 20 | id: streamID, 21 | sc: sc, 22 | } 23 | streams[i].flow.add(1 << 20) // arbitrary large value 24 | ws.OpenStream(streamID, OpenStreamOptions{}) 25 | wr := FrameWriteRequest{ 26 | write: &writeData{ 27 | streamID: streamID, 28 | p: make([]byte, maxFrameSize*(i+1)), 29 | endStream: false, 30 | }, 31 | stream: streams[i], 32 | } 33 | ws.Push(wr) 34 | } 35 | const controlFrames = 2 36 | for i := 0; i < controlFrames; i++ { 37 | ws.Push(makeWriteNonStreamRequest()) 38 | } 39 | 40 | // We should get the control frames first. 41 | for i := 0; i < controlFrames; i++ { 42 | wr, ok := ws.Pop() 43 | if !ok || wr.StreamID() != 0 { 44 | t.Fatalf("wr.Pop() = stream %v, %v; want 0, true", wr.StreamID(), ok) 45 | } 46 | } 47 | 48 | // Each stream should write maxFrameSize bytes until it runs out of data. 49 | // Stream 1 has one frame of data, 2 has two frames, etc. 50 | want := []uint32{1, 2, 3, 4, 2, 3, 4, 3, 4, 4} 51 | var got []uint32 52 | for { 53 | wr, ok := ws.Pop() 54 | if !ok { 55 | break 56 | } 57 | if wr.DataSize() != maxFrameSize { 58 | t.Fatalf("wr.Pop() = %v data bytes, want %v", wr.DataSize(), maxFrameSize) 59 | } 60 | got = append(got, wr.StreamID()) 61 | } 62 | if !reflect.DeepEqual(got, want) { 63 | t.Fatalf("popped streams %v, want %v", got, want) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pkg/ja3/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Salesforce.com, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /pkg/ja3/sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # The JA3 algoritm source code is cloned from https://github.com/dreadl0ck/ja3. 5 | # 6 | # This package imports https://github.com/google/gopacket which depends on pcap. However, 7 | # Fingerproxy does not need pcap and it causes trouble while cross compiling, for example, 8 | # it requires to install libpcap-dev which makes no sense for Fingerproxy. Therefore, we 9 | # decided to take the shortcut - copy-paste the algorithm part source code. 10 | # 11 | 12 | cd $(dirname "$0") 13 | 14 | wget https://raw.githubusercontent.com/dreadl0ck/ja3/master/ja3.go 15 | -------------------------------------------------------------------------------- /pkg/ja4/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 FoxIO 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of FoxIO nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /pkg/ja4/helper.go: -------------------------------------------------------------------------------- 1 | package ja4 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "fmt" 7 | "sort" 8 | ) 9 | 10 | func sortUint16(sl []uint16) { 11 | sort.Slice(sl, func(x int, y int) bool { return sl[x] < sl[y] }) 12 | } 13 | 14 | func joinUint16(slice []uint16, sep string) string { 15 | var buffer bytes.Buffer 16 | for i, u := range slice { 17 | if i != 0 { 18 | buffer.WriteString(sep) 19 | } 20 | buffer.WriteString(fmt.Sprintf("%04x", u)) 21 | } 22 | return buffer.String() 23 | } 24 | 25 | func isGREASEUint16(v uint16) bool { 26 | // First byte is same as second byte 27 | // and lowest nibble is 0xa 28 | return ((v >> 8) == v&0xff) && v&0xf == 0xa 29 | } 30 | 31 | func truncatedSha256(in string) string { 32 | sha := sha256.New() 33 | sha.Write([]byte(in)) 34 | return fmt.Sprintf("%x", sha.Sum(nil))[:12] 35 | } 36 | -------------------------------------------------------------------------------- /pkg/ja4/types.go: -------------------------------------------------------------------------------- 1 | package ja4 2 | 3 | import ( 4 | "fmt" 5 | 6 | utls "github.com/refraction-networking/utls" 7 | ) 8 | 9 | type ( 10 | tlsVersion uint16 11 | numberOfCipherSuites int 12 | numberOfExtensions int 13 | 14 | cipherSuites []uint16 15 | extensions []uint16 16 | signatureAlgorithms []uint16 17 | ) 18 | 19 | func (x tlsVersion) String() string { 20 | switch uint16(x) { 21 | case utls.VersionTLS10: 22 | return "10" 23 | case utls.VersionTLS11: 24 | return "11" 25 | case utls.VersionTLS12: 26 | return "12" 27 | case utls.VersionTLS13: 28 | return "13" 29 | } 30 | return "00" 31 | } 32 | func (x numberOfCipherSuites) String() string { return fmt.Sprintf("%02d", min(x, 99)) } 33 | func (x numberOfExtensions) String() string { return fmt.Sprintf("%02d", min(x, 99)) } 34 | func (x cipherSuites) String() string { return joinUint16(x, cipherSuitesSeparator) } 35 | func (x extensions) String() string { return joinUint16(x, extensionsSeparator) } 36 | func (x signatureAlgorithms) String() string { return joinUint16(x, signatureAlgorithmSeparator) } 37 | -------------------------------------------------------------------------------- /pkg/ja4pcap/pcap.go: -------------------------------------------------------------------------------- 1 | // Package `ja4pcap` is just a test helper for the `ja4` package that adopts 2 | // the test cases from the [official JA4 repo]. Fingerproxy does not import 3 | // this package. 4 | // 5 | // Maintainers should regularlly run ./sync-testdata.sh to follow the upstream. 6 | // 7 | // It is also a demonstration of using JA4 package with `gopacket`. 8 | // 9 | // [official JA4 repo]: https://github.com/FoxIO-LLC/ja4/tree/main/pcap 10 | package ja4pcap 11 | 12 | import ( 13 | "errors" 14 | "io" 15 | "os" 16 | "slices" 17 | 18 | "cmp" 19 | 20 | "github.com/google/gopacket" 21 | "github.com/google/gopacket/layers" 22 | "github.com/google/gopacket/pcapgo" 23 | "github.com/wi1dcard/fingerproxy/pkg/ja4" 24 | ) 25 | 26 | type pcapClientHello struct { 27 | tcpStreamTuple 28 | StreamIndex int 29 | JA4 string 30 | } 31 | 32 | type tcpStreamTuple struct { 33 | SrcIP string 34 | SrcPort uint16 35 | DstIP string 36 | DstPort uint16 37 | } 38 | 39 | // returns true if either: 40 | // a) t1.src == t2.src and t1.dst == t2.dst 41 | // b) t1.src == t2.dst and t1.dst == t2.src 42 | func (t1 *tcpStreamTuple) Equal(t2 tcpStreamTuple) bool { 43 | // match tuple [address A + port A, address B + port B] 44 | if t1.DstIP == t2.DstIP && t1.DstPort == t2.DstPort && 45 | t1.SrcIP == t2.SrcIP && t1.SrcPort == t2.SrcPort { 46 | return true 47 | } 48 | if t1.DstIP == t2.SrcIP && t1.DstPort == t2.SrcPort && 49 | t1.SrcIP == t2.DstIP && t1.SrcPort == t2.DstPort { 50 | return true 51 | } 52 | return false 53 | } 54 | 55 | func openPcap(f *os.File) (gopacket.PacketDataSource, layers.LinkType) { 56 | pcapReader, errPcap := pcapgo.NewReader(f) 57 | if errPcap == nil { 58 | return pcapReader, pcapReader.LinkType() 59 | } 60 | 61 | f.Seek(0, io.SeekStart) 62 | ngReader, errPcapNg := pcapgo.NewNgReader(f, pcapgo.DefaultNgReaderOptions) 63 | if errPcapNg != nil { 64 | panic(errPcapNg) 65 | } 66 | 67 | return ngReader, ngReader.LinkType() 68 | } 69 | 70 | var errPacketIsNotClientHello = errors.New("packet is not a client hello") 71 | 72 | func ja4FromPacket(tcp *layers.TCP) (string, error) { 73 | if tcp.SYN || tcp.FIN || tcp.RST { 74 | return "", errPacketIsNotClientHello 75 | } 76 | 77 | pl := tcp.LayerPayload() 78 | if len(pl) == 0 { 79 | return "", errPacketIsNotClientHello 80 | } 81 | 82 | j := ja4.JA4Fingerprint{} 83 | err := j.UnmarshalBytes(pl, 't') 84 | if err != nil { 85 | if ie := errors.Unwrap(err); ie != nil { 86 | err = ie 87 | } 88 | switch err.Error() { 89 | // utls cannot parse the ClientHello 90 | case "record is not a handshake": 91 | return "", errPacketIsNotClientHello 92 | case "handshake message is not a ClientHello": 93 | return "", errPacketIsNotClientHello 94 | case "unable to read record type, version, and length": 95 | return "", errPacketIsNotClientHello 96 | case "unable to read handshake message type, length, and random": 97 | return "", errPacketIsNotClientHello 98 | // otherwise returns error 99 | default: 100 | return "", err 101 | } 102 | } 103 | 104 | return j.String(), nil 105 | } 106 | 107 | func readPcap(file string) []pcapClientHello { 108 | f, err := os.Open(file) 109 | if err != nil { 110 | panic(err) 111 | } 112 | defer f.Close() 113 | 114 | packetDataSource, linkType := openPcap(f) 115 | packetSource := gopacket.NewPacketSource(packetDataSource, linkType) 116 | 117 | var clientHellos []pcapClientHello 118 | var tcpStreams []tcpStreamTuple 119 | 120 | for p := range packetSource.Packets() { 121 | ch := pcapClientHello{} 122 | srcdst := tcpStreamTuple{} 123 | 124 | if ipv4Layer := p.Layer(layers.LayerTypeIPv4); ipv4Layer != nil { 125 | ip, _ := ipv4Layer.(*layers.IPv4) 126 | srcdst.DstIP = ip.DstIP.String() 127 | srcdst.SrcIP = ip.SrcIP.String() 128 | } else if ipv6Layer := p.Layer(layers.LayerTypeIPv6); ipv6Layer != nil { 129 | ip, _ := ipv6Layer.(*layers.IPv6) 130 | srcdst.DstIP = ip.DstIP.String() 131 | srcdst.SrcIP = ip.SrcIP.String() 132 | } else { 133 | // not IPv4 or IPv6 134 | continue 135 | } 136 | 137 | tcpLayer := p.Layer(layers.LayerTypeTCP) 138 | if tcpLayer == nil { 139 | // not TCP 140 | continue 141 | } 142 | 143 | tcp, _ := tcpLayer.(*layers.TCP) 144 | srcdst.DstPort = uint16(tcp.DstPort) 145 | srcdst.SrcPort = uint16(tcp.SrcPort) 146 | 147 | // get wireshark stream index before parsing JA4 148 | ch.StreamIndex = slices.IndexFunc(tcpStreams, srcdst.Equal) 149 | if ch.StreamIndex == -1 { 150 | ch.StreamIndex = len(tcpStreams) 151 | tcpStreams = append(tcpStreams, srcdst) 152 | } 153 | 154 | ch.JA4, err = ja4FromPacket(tcp) 155 | if err == nil { 156 | ch.tcpStreamTuple = srcdst 157 | clientHellos = append(clientHellos, ch) 158 | } else if !errors.Is(err, errPacketIsNotClientHello) { 159 | panic(err) 160 | } 161 | } 162 | 163 | // sort by stream index 164 | slices.SortFunc(clientHellos, func(x pcapClientHello, y pcapClientHello) int { 165 | return cmp.Compare(x.StreamIndex, y.StreamIndex) 166 | }) 167 | 168 | return clientHellos 169 | } 170 | -------------------------------------------------------------------------------- /pkg/ja4pcap/pcap_test.go: -------------------------------------------------------------------------------- 1 | package ja4pcap 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | type snap struct { 12 | StreamIndex int `yaml:"stream"` 13 | Transport string `yaml:"transport"` 14 | 15 | SrcIP string `yaml:"src"` 16 | SrcPort uint16 `yaml:"src_port"` 17 | DstIP string `yaml:"dst"` 18 | DstPort uint16 `yaml:"dst_port"` 19 | 20 | JA4 string `yaml:"ja4"` 21 | } 22 | 23 | func TestPcap(t *testing.T) { 24 | pcapFiles, err := os.ReadDir("testdata/pcap/") 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | for _, pf := range pcapFiles { 30 | if pf.IsDir() { 31 | continue 32 | } 33 | 34 | t.Run(pf.Name(), func(t *testing.T) { 35 | clientHellos := readPcap("testdata/pcap/" + pf.Name()) 36 | snapFilename := fmt.Sprintf("testdata/snapshots/ja4__insta@%s.snap", pf.Name()) 37 | clientHelloSnapshots := readSnapshot(t, snapFilename) 38 | 39 | expLen := len(clientHelloSnapshots) 40 | if actLen := len(clientHellos); expLen != actLen { 41 | t.Fatalf("expected %d client hello records, actual %d", expLen, actLen) 42 | } 43 | 44 | for i := 0; i < expLen; i++ { 45 | exp := clientHelloSnapshots[i] 46 | act := clientHellos[i] 47 | 48 | if act.StreamIndex != exp.StreamIndex { 49 | t.Errorf("expected stream index %d, actual %d", exp.StreamIndex, act.StreamIndex) 50 | } 51 | if act.DstIP != exp.DstIP { 52 | t.Errorf("[%d] expected dst IP %s, actual %s", exp.StreamIndex, exp.DstIP, act.DstIP) 53 | } 54 | if act.DstPort != exp.DstPort { 55 | t.Errorf("[%d] expected dst port %d, actual %d", exp.StreamIndex, exp.DstPort, act.DstPort) 56 | } 57 | if act.SrcIP != exp.SrcIP { 58 | t.Errorf("[%d] expected src IP %s, actual %s", exp.StreamIndex, exp.SrcIP, act.SrcIP) 59 | } 60 | if act.SrcPort != exp.SrcPort { 61 | t.Errorf("[%d] expected src port %d, actual %d", exp.StreamIndex, exp.SrcPort, act.SrcPort) 62 | } 63 | if act.JA4 != exp.JA4 { 64 | t.Errorf("[%d] expected JA4 fingerprint %s, actual %s", exp.StreamIndex, exp.JA4, act.JA4) 65 | } 66 | } 67 | }) 68 | } 69 | } 70 | 71 | func readSnapshot(t *testing.T, snapFilename string) []snap { 72 | t.Helper() 73 | 74 | snapFileBytes, err := os.Open(snapFilename) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | defer snapFileBytes.Close() 79 | 80 | var ( 81 | snapshots, filteredSnapshots []snap 82 | unusedFirstYamlDoc struct{} 83 | ) 84 | 85 | decoder := yaml.NewDecoder(snapFileBytes) 86 | decoder.Decode(&unusedFirstYamlDoc) 87 | decoder.Decode(&snapshots) 88 | 89 | for _, sn := range snapshots { 90 | // udp not supported yet 91 | if sn.JA4 == "" || sn.Transport == "udp" { 92 | continue 93 | } 94 | 95 | filteredSnapshots = append(filteredSnapshots, sn) 96 | } 97 | 98 | return filteredSnapshots 99 | } 100 | -------------------------------------------------------------------------------- /pkg/ja4pcap/sync-testdata.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S bash -exuo pipefail 2 | 3 | # 4 | # Sync the test cases from the official JA4 repo. 5 | # 6 | 7 | cd $(dirname "$0") 8 | 9 | HEAD=main 10 | HEAD_ARCHIVE_FILENAME=$HEAD.tar.gz 11 | LOCAL_ARCHIVE_FILENAME=/tmp/$HEAD_ARCHIVE_FILENAME 12 | 13 | wget -O $LOCAL_ARCHIVE_FILENAME https://github.com/FoxIO-LLC/ja4/archive/refs/heads/$HEAD_ARCHIVE_FILENAME 14 | 15 | TMP_SRCDIR=$(mktemp -d) 16 | TARBALL_ROOTDIR=$(tar tf $LOCAL_ARCHIVE_FILENAME | head -n1) 17 | 18 | tar xzf $LOCAL_ARCHIVE_FILENAME --directory $TMP_SRCDIR 19 | 20 | rsync -avhW --no-compress --delete $TMP_SRCDIR/$TARBALL_ROOTDIR/pcap/ ./testdata/pcap/ 21 | rsync -avhW --no-compress --delete $TMP_SRCDIR/$TARBALL_ROOTDIR/rust/ja4/src/snapshots/ ./testdata/snapshots/ 22 | 23 | rm -rf $TMP_SRCDIR 24 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/CVE-2018-6794.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/CVE-2018-6794.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/badcurveball.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/badcurveball.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/browsers-x509.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/browsers-x509.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/chrome-cloudflare-quic-with-secrets.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/chrome-cloudflare-quic-with-secrets.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/gre-erspan-vxlan.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/gre-erspan-vxlan.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/gre-sample.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/gre-sample.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/http1-with-cookies.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/http1-with-cookies.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/http1.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/http1.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/http2-with-cookies.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/http2-with-cookies.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ipv6.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ipv6.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/latest.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/latest.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/macos_tcp_flags.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/macos_tcp_flags.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/quic-tls-handshake.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/quic-tls-handshake.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/quic-with-several-tls-frames.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/quic-with-several-tls-frames.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/single-packets.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/single-packets.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/socks4-https.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/socks4-https.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh-r.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh-r.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh-scp-1050.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh-scp-1050.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh2-malformed.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh2-malformed.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh2-moloch-crash.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh2-moloch-crash.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/ssh2.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/ssh2.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/sshv1.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/sshv1.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tcpdump-geneve.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tcpdump-geneve.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls-alpn-h2.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls-alpn-h2.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls-handshake.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls-handshake.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls-non-ascii-alpn.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls-non-ascii-alpn.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls-sni.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls-sni.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls12.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls12.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/tls3.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/tls3.pcapng -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/pcap/v6.pcap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wi1dcard/fingerproxy/bcda1920cb3665dedcafebef8eaf008e74393fa9/pkg/ja4pcap/testdata/pcap/v6.pcap -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@CVE-2018-6794.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.235.1 8 | dst: 192.168.235.136 9 | src_port: 53649 10 | dst_port: 8089 11 | ja4l_c: 0_128 12 | ja4l_s: 2219_255 13 | http: 14 | - ja4h: ge11nn07ruru_6cd0fb54989b_000000000000_000000000000 15 | - stream: 1 16 | transport: tcp 17 | src: 192.168.235.1 18 | dst: 192.168.235.136 19 | src_port: 53656 20 | dst_port: 8089 21 | ja4l_c: 0_128 22 | ja4l_s: 1513_255 23 | http: 24 | - ja4h: ge11nr06ruru_cc6ec9a91856_000000000000_000000000000 25 | - stream: 2 26 | transport: tcp 27 | src: 192.168.235.1 28 | dst: 192.168.235.136 29 | src_port: 53648 30 | dst_port: 8089 31 | ja4l_c: 0_128 32 | ja4l_s: 1948_255 33 | 34 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@badcurveball.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 172.130.128.76 8 | dst: 54.226.182.138 9 | src_port: 55318 10 | dst_port: 443 11 | tls_server_name: bad.curveballtest.com 12 | ja4: t13d1615h2_46e7e9700bed_45f260be83e2 13 | ja4s: t1205h1_c02b_845f7282a956 14 | tls_certs: 15 | - x509: 16 | - ja4x: 2e9214a636bc_a373a9f83c6b_0e17604154c5 17 | issuerCountryName: HR 18 | issuerStateOrProvinceName: Zagreb 19 | issuerOrganizationName: INFIGO IS 20 | issuerCommonName: INFIGO 21 | subjectCountryName: US 22 | subjectOrganizationName: SANS Internet Storm Center 23 | subjectCommonName: SANS ISC DShield Test 24 | - ja4x: 2e9214a636bc_2e9214a636bc_795797892f9c 25 | issuerCountryName: HR 26 | issuerStateOrProvinceName: Zagreb 27 | issuerOrganizationName: INFIGO IS 28 | issuerCommonName: INFIGO 29 | subjectCountryName: HR 30 | subjectStateOrProvinceName: Zagreb 31 | subjectOrganizationName: INFIGO IS 32 | subjectCommonName: INFIGO 33 | ja4l_c: 2177_64 34 | ja4l_s: 781_238 35 | 36 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@browsers-x509.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 172.27.7.31 8 | dst: 13.107.21.239 9 | src_port: 54524 10 | dst_port: 443 11 | tls_server_name: edge.microsoft.com 12 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 13 | ja4s: t1206h2_c030_044dc9b3196d 14 | tls_certs: 15 | - x509: 16 | - ja4x: a373a9f83c6b_2bab15409345_0f2217ba412e 17 | issuerCountryName: US 18 | issuerOrganizationName: Microsoft Corporation 19 | issuerCommonName: Microsoft Azure TLS Issuing CA 05 20 | subjectCountryName: US 21 | subjectStateOrProvinceName: WA 22 | subjectLocalityName: Redmond 23 | subjectOrganizationName: Microsoft Corporation 24 | subjectCommonName: edge.microsoft.com 25 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_c34b04c10969 26 | issuerCountryName: US 27 | issuerOrganizationName: DigiCert Inc 28 | issuerOrganizationalUnit: www.digicert.com 29 | issuerCommonName: DigiCert Global Root G2 30 | subjectCountryName: US 31 | subjectOrganizationName: Microsoft Corporation 32 | subjectCommonName: Microsoft Azure TLS Issuing CA 05 33 | ja4l_c: 56_128 34 | ja4l_s: 1907_112 35 | - stream: 1 36 | transport: tcp 37 | src: 172.27.7.31 38 | dst: 68.67.160.117 39 | src_port: 54525 40 | dst_port: 443 41 | tls_server_name: nym1-ib.adnxs.com 42 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 43 | ja4s: t1207h2_c02b_cf25e267ce22 44 | tls_certs: 45 | - x509: 46 | - ja4x: 7d5dbb3783b4_2bab15409345_7bf9a7bf7029 47 | issuerCountryName: US 48 | issuerOrganizationName: DigiCert Inc 49 | issuerOrganizationalUnit: www.digicert.com 50 | issuerCommonName: GeoTrust ECC CA 2018 51 | subjectCountryName: US 52 | subjectStateOrProvinceName: New York 53 | subjectLocalityName: New York 54 | subjectOrganizationName: Xandr Inc. 55 | subjectCommonName: '*.adnxs.com' 56 | - ja4x: 7d5dbb3783b4_7d5dbb3783b4_44440d41940c 57 | issuerCountryName: US 58 | issuerOrganizationName: DigiCert Inc 59 | issuerOrganizationalUnit: www.digicert.com 60 | issuerCommonName: DigiCert Global Root CA 61 | subjectCountryName: US 62 | subjectOrganizationName: DigiCert Inc 63 | subjectOrganizationalUnit: www.digicert.com 64 | subjectCommonName: GeoTrust ECC CA 2018 65 | ja4l_c: 73_128 66 | ja4l_s: 7166_41 67 | - stream: 2 68 | transport: tcp 69 | src: 172.27.7.31 70 | dst: 103.42.133.15 71 | src_port: 54603 72 | dst_port: 443 73 | tls_server_name: lptag.liveperson.net 74 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 75 | ja4s: t1205h2_c02f_845f7282a956 76 | tls_certs: 77 | - x509: 78 | - ja4x: 2bab15409345_2e9214a636bc_b891c0ad6f32 79 | issuerCountryName: GB 80 | issuerStateOrProvinceName: Greater Manchester 81 | issuerLocalityName: Salford 82 | issuerOrganizationName: Sectigo Limited 83 | issuerCommonName: Sectigo RSA Organization Validation Secure Server CA 84 | subjectCountryName: US 85 | subjectStateOrProvinceName: New York 86 | subjectOrganizationName: LivePerson, Inc 87 | subjectCommonName: '*.liveperson.net' 88 | - ja4x: 2bab15409345_2bab15409345_2367ce7fbc5b 89 | issuerCountryName: US 90 | issuerStateOrProvinceName: New Jersey 91 | issuerLocalityName: Jersey City 92 | issuerOrganizationName: The USERTRUST Network 93 | issuerCommonName: USERTrust RSA Certification Authority 94 | subjectCountryName: GB 95 | subjectStateOrProvinceName: Greater Manchester 96 | subjectLocalityName: Salford 97 | subjectOrganizationName: Sectigo Limited 98 | subjectCommonName: Sectigo RSA Organization Validation Secure Server CA 99 | - ja4x: 2bab15409345_2bab15409345_2030e37f3421 100 | issuerCountryName: GB 101 | issuerStateOrProvinceName: Greater Manchester 102 | issuerLocalityName: Salford 103 | issuerOrganizationName: Comodo CA Limited 104 | issuerCommonName: AAA Certificate Services 105 | subjectCountryName: US 106 | subjectStateOrProvinceName: New Jersey 107 | subjectLocalityName: Jersey City 108 | subjectOrganizationName: The USERTRUST Network 109 | subjectCommonName: USERTrust RSA Certification Authority 110 | ja4l_c: 78_128 111 | ja4l_s: 2948_229 112 | 113 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@chrome-cloudflare-quic-with-secrets.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 2001:db8:1::1 8 | dst: 2606:4700:10::6816:826 9 | src_port: 57098 10 | dst_port: 443 11 | tls_server_name: cloudflare-quic.com 12 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 13 | ja4s: t130200_1301_234ea6891581 14 | ja4l_c: 30_64 15 | ja4l_s: 5749_56 16 | http: 17 | - ja4h: ge20nn16enus_0f5a7a41a252_000000000000_000000000000 18 | - stream: 0 19 | transport: udp 20 | src: 2001:db8:1::1 21 | dst: 2606:4700:10::6816:826 22 | src_port: 50280 23 | dst_port: 443 24 | tls_server_name: cloudflare-quic.com 25 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 26 | ja4s: q130200_1301_234ea6891581 27 | ja4l_c: 113_64 28 | ja4l_s: 9285_56 29 | 30 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@gre-erspan-vxlan.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 10.16.27.12 8 | dst: 10.16.27.131 9 | src_port: 65174 10 | dst_port: 80 11 | ja4l_c: 953_64 12 | ja4l_s: 997_64 13 | 14 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@gre-sample.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 66.59.111.190 8 | dst: 172.28.2.3 9 | src_port: 40264 10 | dst_port: 22 11 | ja4l_c: 36_255 12 | ja4l_s: 22952_236 13 | ja4ssh: 14 | - c24s23_c4s4_c6s4 15 | ssh_extras: 16 | hassh: 5ef6678a6b060094834599ca16581b05 17 | hassh_server: 6e3242d64766f4154c11858bbd654415 18 | ssh_protocol_client: SSH-2.0-OpenSSH_3.6.1p1 19 | ssh_protocol_server: SSH-1.99-OpenSSH_3.1p1 20 | encryption_algorithm: aes128-cbc 21 | 22 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@gtp-iphone.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: fd00:183:1:1:1886:9040:8605:32b8 8 | dst: fd01::183 9 | src_port: 5060 10 | dst_port: 5060 11 | ja4l_c: 15922_64 12 | ja4l_s: 53_64 13 | - stream: 1 14 | transport: tcp 15 | src: fd00:183:1:1:1886:9040:8605:32b8 16 | dst: fd01::183 17 | src_port: 5060 18 | dst_port: 5060 19 | ja4l_c: 16068_64 20 | ja4l_s: 52_64 21 | - stream: 2 22 | transport: tcp 23 | src: fd00:183:1:1:1886:9040:8605:32b8 24 | dst: fd01::183 25 | src_port: 5060 26 | dst_port: 5060 27 | ja4l_c: 19889_64 28 | ja4l_s: 174_64 29 | - stream: 3 30 | transport: tcp 31 | src: fd00:183:1:1:1886:9040:8605:32b8 32 | dst: fd01::183 33 | src_port: 5060 34 | dst_port: 5060 35 | ja4l_c: 19962_64 36 | ja4l_s: 35_64 37 | 38 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@http1-with-cookies.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 127.0.0.1 8 | dst: 127.0.0.1 9 | src_port: 61256 10 | dst_port: 8000 11 | ja4l_c: 14_64 12 | ja4l_s: 64_64 13 | http: 14 | - ja4h: ge11cr04da00_8ddaef5d77af_280f366eaa04_c2fb0fe53442 15 | 16 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@http2-with-cookies.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.2.200 8 | dst: 142.250.187.206 9 | src_port: 58847 10 | dst_port: 443 11 | tls_server_name: youtube.com 12 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 13 | ja4s: t130200_1301_234ea6891581 14 | tls_certs: 15 | - x509: 16 | - ja4x: a373a9f83c6b_7022c563de38_2e3757343cb0 17 | issuerCountryName: US 18 | issuerOrganizationName: Google Trust Services LLC 19 | issuerCommonName: GTS CA 1C3 20 | subjectCommonName: '*.google.com' 21 | - ja4x: a373a9f83c6b_a373a9f83c6b_5d71497f7704 22 | issuerCountryName: US 23 | issuerOrganizationName: Google Trust Services LLC 24 | issuerCommonName: GTS Root R1 25 | subjectCountryName: US 26 | subjectOrganizationName: Google Trust Services LLC 27 | subjectCommonName: GTS CA 1C3 28 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_2fbee3f04f3b 29 | issuerCountryName: BE 30 | issuerOrganizationName: GlobalSign nv-sa 31 | issuerOrganizationalUnit: Root CA 32 | issuerCommonName: GlobalSign Root CA 33 | subjectCountryName: US 34 | subjectOrganizationName: Google Trust Services LLC 35 | subjectCommonName: GTS Root R1 36 | ja4l_c: 47_128 37 | ja4l_s: 44840_117 38 | http: 39 | - ja4h: ge20cn23enus_641f0b6ae3f0_c7713052b7e4_348cad68b6fb 40 | - ja4h: ge20cn17enus_949f364da66f_e43af2e8abfe_015bb0ca5596 41 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 42 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 43 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 44 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 45 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 46 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 47 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 48 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 49 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 50 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 51 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 52 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 53 | - ja4h: ge20cr22enus_265608141a12_10ff48fdaa11_ac323afc21f7 54 | 55 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ipv6.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 2001:4998:ef83:14:8000::100d 8 | dst: 2606:4700::6811:d209 9 | src_port: 64034 10 | dst_port: 443 11 | tls_server_name: www.cloudflare.com 12 | ja4: t12d4605h2_85626a9a5f7f_aaf95bb78ec9 13 | ja4s: t1204h2_cca9_1428ce7b4018 14 | tls_certs: 15 | - x509: 16 | - ja4x: 7d5dbb3783b4_ba7ce0880c07_7bf9a7bf7029 17 | issuerCountryName: US 18 | issuerOrganizationName: DigiCert Inc 19 | issuerOrganizationalUnit: www.digicert.com 20 | issuerCommonName: DigiCert ECC Extended Validation Server CA 21 | subjectBusinessCategory: Private Organization 22 | subjectMsJurisdictionCountry: US 23 | subjectMsJurisdictionStateOrProvince: Delaware 24 | subjectSerialNumber: '4710875' 25 | subjectCountryName: US 26 | subjectStateOrProvinceName: California 27 | subjectLocalityName: San Francisco 28 | subjectOrganizationName: Cloudflare, Inc. 29 | subjectCommonName: cloudflare.com 30 | - ja4x: 7d5dbb3783b4_7d5dbb3783b4_41a019652939 31 | issuerCountryName: US 32 | issuerOrganizationName: DigiCert Inc 33 | issuerOrganizationalUnit: www.digicert.com 34 | issuerCommonName: DigiCert High Assurance EV Root CA 35 | subjectCountryName: US 36 | subjectOrganizationName: DigiCert Inc 37 | subjectOrganizationalUnit: www.digicert.com 38 | subjectCommonName: DigiCert ECC Extended Validation Server CA 39 | ja4l_c: 35_64 40 | ja4l_s: 18861_59 41 | 42 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@latest.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 1 6 | transport: tcp 7 | src: 172.16.225.48 8 | dst: 34.212.93.65 9 | src_port: 52936 10 | dst_port: 443 11 | tls_server_name: pdx-col.eum-appdynamics.com 12 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 13 | ja4s: t1206h2_c02f_3603f09c43ba 14 | tls_certs: 15 | - x509: 16 | - ja4x: a373a9f83c6b_2bab15409345_7bf9a7bf7029 17 | issuerCountryName: US 18 | issuerOrganizationName: DigiCert Inc 19 | issuerCommonName: DigiCert Global G2 TLS RSA SHA256 2020 CA1 20 | subjectCountryName: US 21 | subjectStateOrProvinceName: California 22 | subjectLocalityName: San Francisco 23 | subjectOrganizationName: AppDynamics LLC 24 | subjectCommonName: '*.eum-appdynamics.com' 25 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_a83ffcd6e6c2 26 | issuerCountryName: US 27 | issuerOrganizationName: DigiCert Inc 28 | issuerOrganizationalUnit: www.digicert.com 29 | issuerCommonName: DigiCert Global Root G2 30 | subjectCountryName: US 31 | subjectOrganizationName: DigiCert Inc 32 | subjectCommonName: DigiCert Global G2 TLS RSA SHA256 2020 CA1 33 | ja4l_c: 62_128 34 | ja4l_s: 33804_227 35 | - stream: 3 36 | transport: tcp 37 | src: 172.16.225.48 38 | dst: 13.33.165.101 39 | src_port: 52937 40 | dst_port: 443 41 | tls_server_name: discovery.cem.cloud.us 42 | ja4: t12d190800_d83cc789557e_7af1ed941c26 43 | ja4s: t120600_c02f_51ad275821ba 44 | tls_certs: 45 | - x509: 46 | - ja4x: a373a9f83c6b_2bab15409345_7bf9a7bf7029 47 | issuerCountryName: US 48 | issuerOrganizationName: DigiCert Inc 49 | issuerCommonName: DigiCert TLS RSA SHA256 2020 CA1 50 | subjectCountryName: US 51 | subjectStateOrProvinceName: Florida 52 | subjectLocalityName: Fort Lauderdale 53 | subjectOrganizationName: Citrix Systems, Inc. 54 | subjectCommonName: '*.cem.cloud.us' 55 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_a83ffcd6e6c2 56 | issuerCountryName: US 57 | issuerOrganizationName: DigiCert Inc 58 | issuerOrganizationalUnit: www.digicert.com 59 | issuerCommonName: DigiCert Global Root CA 60 | subjectCountryName: US 61 | subjectOrganizationName: DigiCert Inc 62 | subjectCommonName: DigiCert TLS RSA SHA256 2020 CA1 63 | ja4l_c: 57_128 64 | ja4l_s: 7096_245 65 | - stream: 5 66 | transport: tcp 67 | src: 172.16.225.48 68 | dst: 34.205.195.66 69 | src_port: 52938 70 | dst_port: 443 71 | tls_server_name: app.slack.com 72 | ja4: t13d1516h2_8daaf6152771_9b887d9acb53 73 | ja4s: t130300_1301_6bbbaf601ed8 74 | ja4l_c: 47_128 75 | ja4l_s: 14207_43 76 | - stream: 6 77 | transport: tcp 78 | src: 172.16.225.48 79 | dst: 23.43.242.57 80 | src_port: 52939 81 | dst_port: 80 82 | ja4l_c: 32_128 83 | ja4l_s: 3915_57 84 | http: 85 | - ja4h: ge11nn07enus_3e3b55d61660_000000000000_000000000000 86 | - stream: 9 87 | transport: tcp 88 | src: 172.16.225.48 89 | dst: 52.249.29.248 90 | src_port: 52940 91 | dst_port: 443 92 | tls_server_name: ping-edge.smartscreen.microsoft.com 93 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 94 | ja4s: t120300_c030_09f674154ab3 95 | tls_certs: 96 | - x509: 97 | - ja4x: a373a9f83c6b_2bab15409345_0f2217ba412e 98 | issuerCountryName: US 99 | issuerOrganizationName: Microsoft Corporation 100 | issuerCommonName: Microsoft Azure TLS Issuing CA 05 101 | subjectCountryName: US 102 | subjectStateOrProvinceName: WA 103 | subjectLocalityName: Redmond 104 | subjectOrganizationName: Microsoft Corporation 105 | subjectCommonName: smartscreen.microsoft.com 106 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_c34b04c10969 107 | issuerCountryName: US 108 | issuerOrganizationName: DigiCert Inc 109 | issuerOrganizationalUnit: www.digicert.com 110 | issuerCommonName: DigiCert Global Root G2 111 | subjectCountryName: US 112 | subjectOrganizationName: Microsoft Corporation 113 | subjectCommonName: Microsoft Azure TLS Issuing CA 05 114 | ja4l_c: 40_128 115 | ja4l_s: 42103_109 116 | - stream: 10 117 | transport: tcp 118 | src: 172.16.225.48 119 | dst: 52.249.29.248 120 | src_port: 52941 121 | dst_port: 443 122 | tls_server_name: data-edge.smartscreen.microsoft.com 123 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 124 | ja4s: t120300_c030_09f674154ab3 125 | tls_certs: 126 | - x509: 127 | - ja4x: a373a9f83c6b_2bab15409345_0f2217ba412e 128 | issuerCountryName: US 129 | issuerOrganizationName: Microsoft Corporation 130 | issuerCommonName: Microsoft Azure TLS Issuing CA 05 131 | subjectCountryName: US 132 | subjectStateOrProvinceName: WA 133 | subjectLocalityName: Redmond 134 | subjectOrganizationName: Microsoft Corporation 135 | subjectCommonName: smartscreen.microsoft.com 136 | - ja4x: 7d5dbb3783b4_a373a9f83c6b_c34b04c10969 137 | issuerCountryName: US 138 | issuerOrganizationName: DigiCert Inc 139 | issuerOrganizationalUnit: www.digicert.com 140 | issuerCommonName: DigiCert Global Root G2 141 | subjectCountryName: US 142 | subjectOrganizationName: Microsoft Corporation 143 | subjectCommonName: Microsoft Azure TLS Issuing CA 05 144 | ja4l_c: 61_128 145 | ja4l_s: 53595_109 146 | 147 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@macos_tcp_flags.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 172.16.5.16 8 | dst: 172.67.24.71 9 | src_port: 61311 10 | dst_port: 443 11 | tls_server_name: venarisecurity.com 12 | ja4: t13d2613h2_2802a3db6c62_845d286b0d67 13 | ja4s: t130200_1301_234ea6891581 14 | ja4l_c: 62_64 15 | ja4l_s: 17255_63 16 | 17 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@quic-tls-handshake.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: udp 7 | src: 192.168.1.168 8 | dst: 142.251.163.147 9 | src_port: 59102 10 | dst_port: 443 11 | tls_server_name: www.google.com 12 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 13 | 14 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@quic-with-several-tls-frames.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: udp 7 | src: 192.168.1.168 8 | dst: 142.251.16.100 9 | src_port: 55906 10 | dst_port: 443 11 | tls_server_name: ogs.google.com 12 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 13 | 14 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@single-packets.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.25.150 8 | dst: 74.125.24.149 9 | src_port: 49677 10 | dst_port: 80 11 | http: 12 | - ja4h: ge11cr06enus_8c2f9ef95269_2a79f5d9f8b3_7b4d78c057bc 13 | - stream: 1 14 | transport: tcp 15 | src: 192.168.25.150 16 | dst: 118.215.80.242 17 | src_port: 49654 18 | dst_port: 80 19 | http: 20 | - ja4h: ge11cr07enus_45c71a3fb6ea_a25bf252eb59_43a9e3e95c85 21 | - stream: 2 22 | transport: tcp 23 | src: 192.168.25.150 24 | dst: 13.115.50.210 25 | src_port: 49683 26 | dst_port: 80 27 | http: 28 | - ja4h: ge11nr06enus_8c2f9ef95269_000000000000_000000000000 29 | - stream: 3 30 | transport: tcp 31 | src: 192.168.25.150 32 | dst: 104.89.119.175 33 | src_port: 49708 34 | dst_port: 80 35 | http: 36 | - ja4h: po11cr09enus_130d8cd1913c_f81c0e5c6793_90689f748de6 37 | - stream: 4 38 | transport: tcp 39 | src: 192.168.25.150 40 | dst: 193.242.192.43 41 | src_port: 49735 42 | dst_port: 80 43 | http: 44 | - ja4h: ge11cr07enus_45c71a3fb6ea_9ee64e91aa30_109254663367 45 | - stream: 5 46 | transport: tcp 47 | src: 192.168.25.150 48 | dst: 74.125.24.100 49 | src_port: 49733 50 | dst_port: 80 51 | http: 52 | - ja4h: ge11nr06enus_8c2f9ef95269_000000000000_000000000000 53 | - stream: 6 54 | transport: tcp 55 | src: 192.168.25.150 56 | dst: 74.125.24.95 57 | src_port: 49743 58 | dst_port: 80 59 | http: 60 | - ja4h: ge11nr06enus_8c2f9ef95269_000000000000_000000000000 61 | - stream: 7 62 | transport: tcp 63 | src: 192.168.25.150 64 | dst: 35.174.150.168 65 | src_port: 49738 66 | dst_port: 80 67 | http: 68 | - ja4h: ge11cr06enus_8c2f9ef95269_d23bf79698dc_69e42fa741fe 69 | 70 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@socks4-https.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 10.0.0.1 8 | dst: 10.0.0.2 9 | src_port: 50606 10 | dst_port: 9901 11 | ja4l_c: 119349_126 12 | ja4l_s: 40155_52 13 | 14 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ssh-r.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.1.169 8 | dst: 192.168.1.197 9 | src_port: 64980 10 | dst_port: 22 11 | ja4l_c: 94_128 12 | ja4l_s: 32_64 13 | ja4ssh: 14 | - c64s64_c107s93_c74s10 15 | - c64s64_c33s48_c42s2 16 | ssh_extras: 17 | hassh: e77c2db7432e8cfbc42a96909a84fc8e 18 | hassh_server: 6832f1ce43d4397c2c0a3e2f8c94334e 19 | ssh_protocol_client: SSH-2.0-PuTTY_Release_0.74 20 | ssh_protocol_server: SSH-2.0-OpenSSH_7.4 21 | encryption_algorithm: chacha20-poly1305@openssh.com 22 | - stream: 1 23 | transport: tcp 24 | src: 192.168.1.197 25 | dst: 44.212.59.210 26 | src_port: 46394 27 | dst_port: 22 28 | ja4l_c: 14_64 29 | ja4l_s: 4171_116 30 | ja4ssh: 31 | - c48s21_c7s5_c5s5 32 | ssh_extras: 33 | hassh: ec9ea89c70f5fc71cf61061bff5e4740 34 | hassh_server: 2307c390c7c9aba5b4c9519e72347f34 35 | ssh_protocol_client: SSH-2.0-OpenSSH_7.4 36 | ssh_protocol_server: SSH-2.0-OpenSSH_8.7 37 | encryption_algorithm: aes256-gcm@openssh.com 38 | - stream: 2 39 | transport: tcp 40 | src: 192.168.1.197 41 | dst: 44.212.59.210 42 | src_port: 46396 43 | dst_port: 22 44 | ja4l_c: 12_64 45 | ja4l_s: 3169_116 46 | ja4ssh: 47 | - c76s76_c104s96_c19s82 48 | - c76s76_c108s92_c0s105 49 | - c76s76_c106s94_c0s107 50 | - c76s76_c111s89_c0s102 51 | - c76s76_c67s65_c10s52 52 | ssh_extras: 53 | hassh: ec9ea89c70f5fc71cf61061bff5e4740 54 | hassh_server: 2307c390c7c9aba5b4c9519e72347f34 55 | ssh_protocol_client: SSH-2.0-OpenSSH_7.4 56 | ssh_protocol_server: SSH-2.0-OpenSSH_8.7 57 | encryption_algorithm: aes256-gcm@openssh.com 58 | 59 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ssh-scp-1050.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.1.169 8 | dst: 192.168.1.197 9 | src_port: 49237 10 | dst_port: 22 11 | ja4l_c: 179_128 12 | ja4l_s: 38_64 13 | ja4ssh: 14 | - c112s1460_c52s148_c41s4 15 | - c112s1460_c13s187_c35s0 16 | - c0s1460_c0s200_c36s0 17 | - c0s1460_c0s200_c23s0 18 | - c0s1460_c0s53_c6s0 19 | ssh_extras: 20 | hassh: eb6d4c713c7dcaba7cfd070b095213a9 21 | hassh_server: 6832f1ce43d4397c2c0a3e2f8c94334e 22 | ssh_protocol_client: SSH-2.0-WinSCP_release_5.17.10 23 | ssh_protocol_server: SSH-2.0-OpenSSH_7.4 24 | encryption_algorithm: chacha20-poly1305@openssh.com 25 | 26 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ssh.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 172.16.225.48 8 | dst: 54.160.114.75 9 | src_port: 57377 10 | dst_port: 22 11 | ja4ssh: 12 | - c36s36_c76s124_c0s0 13 | - c36s52_c42s76_c0s0 14 | ssh_extras: 15 | hassh: 06046964c022c6407d15a27b12a6a4fb 16 | hassh_server: 699519fdcc30cbcd093d5cd01e4b1d56 17 | ssh_protocol_client: SSH-2.0-OpenSSH_7.6p1 Ubuntu-4ubuntu0.5 18 | ssh_protocol_server: SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1 19 | encryption_algorithm: chacha20-poly1305@openssh.com 20 | 21 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ssh2-malformed.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 10.0.0.1 8 | dst: 10.0.0.2 9 | src_port: 61672 10 | dst_port: 22 11 | ja4l_c: 7_64 12 | ja4l_s: 462_60 13 | ja4ssh: 14 | - c16s23_c7s5_c3s4 15 | ssh_extras: 16 | hassh: 21b457a327ce7a2d4fce5ef2c42400bd 17 | hassh_server: f430cd6761697a6a658ee1d45ed22e49 18 | ssh_protocol_client: SSH-2.0-OpenSSH_5.3 19 | ssh_protocol_server: SSH-1.99-OpenSSH_3.9p1 20 | encryption_algorithm: aes128-cbc 21 | 22 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@ssh2-moloch-crash.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 10.0.0.1 8 | dst: 10.0.0.2 9 | src_port: 61672 10 | dst_port: 22 11 | ja4l_c: 7_64 12 | ja4l_s: 462_60 13 | ja4ssh: 14 | - c16s23_c7s5_c3s4 15 | ssh_extras: 16 | hassh: 21b457a327ce7a2d4fce5ef2c42400bd 17 | hassh_server: f430cd6761697a6a658ee1d45ed22e49 18 | ssh_protocol_client: SSH-2.0-OpenSSH_5.3 19 | ssh_protocol_server: SSH-1.99-OpenSSH_3.9p1 20 | encryption_algorithm: aes128-cbc 21 | 22 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@sshv1.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 3ffe:507:0:1:200:86ff:fe05:80da 8 | dst: 3ffe:501:410:0:2c0:dfff:fe47:33e 9 | src_port: 1022 10 | dst_port: 22 11 | ja4l_c: 271_64 12 | ja4l_s: 28494_61 13 | ja4ssh: 14 | - c20s12_c18s23_c11s2 15 | ssh_extras: 16 | hassh: null 17 | hassh_server: null 18 | ssh_protocol_client: SSH-1.5-1.2.26 19 | ssh_protocol_server: SSH-1.5-1.2.26 20 | encryption_algorithm: null 21 | 22 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@tcpdump-geneve.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 30.0.0.2 8 | dst: 30.0.0.1 9 | src_port: 51225 10 | dst_port: 22 11 | ja4l_c: 93_64 12 | ja4l_s: 24_64 13 | ja4ssh: 14 | - c144s48_c10s11_c6s4 15 | ssh_extras: 16 | hassh: 21b457a327ce7a2d4fce5ef2c42400bd 17 | hassh_server: ce3c327f37ea2ec21f317fbc3fd1ea43 18 | ssh_protocol_client: SSH-2.0-OpenSSH_5.3 19 | ssh_protocol_server: SSH-2.0-OpenSSH_5.9p1 Debian-5ubuntu1 20 | encryption_algorithm: aes128-ctr 21 | 22 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@tls-alpn-h2.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 2001:4998:ef83:14:8000::100d 8 | dst: 2606:4700::6811:d209 9 | src_port: 64034 10 | dst_port: 443 11 | tls_server_name: www.cloudflare.com 12 | ja4: t12d4605h2_85626a9a5f7f_aaf95bb78ec9 13 | ja4s: t1204h2_cca9_1428ce7b4018 14 | tls_certs: 15 | - x509: 16 | - ja4x: 7d5dbb3783b4_ba7ce0880c07_7bf9a7bf7029 17 | issuerCountryName: US 18 | issuerOrganizationName: DigiCert Inc 19 | issuerOrganizationalUnit: www.digicert.com 20 | issuerCommonName: DigiCert ECC Extended Validation Server CA 21 | subjectBusinessCategory: Private Organization 22 | subjectMsJurisdictionCountry: US 23 | subjectMsJurisdictionStateOrProvince: Delaware 24 | subjectSerialNumber: '4710875' 25 | subjectCountryName: US 26 | subjectStateOrProvinceName: California 27 | subjectLocalityName: San Francisco 28 | subjectOrganizationName: Cloudflare, Inc. 29 | subjectCommonName: cloudflare.com 30 | - ja4x: 7d5dbb3783b4_7d5dbb3783b4_41a019652939 31 | issuerCountryName: US 32 | issuerOrganizationName: DigiCert Inc 33 | issuerOrganizationalUnit: www.digicert.com 34 | issuerCommonName: DigiCert High Assurance EV Root CA 35 | subjectCountryName: US 36 | subjectOrganizationName: DigiCert Inc 37 | subjectOrganizationalUnit: www.digicert.com 38 | subjectCommonName: DigiCert ECC Extended Validation Server CA 39 | ja4l_c: 35_64 40 | ja4l_s: 18861_59 41 | 42 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@tls-non-ascii-alpn.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.1.168 8 | dst: 142.251.16.94 9 | src_port: 50112 10 | dst_port: 443 11 | tls_server_name: clientservices.googleapis.com 12 | ja4: t13d151699_8daaf6152771_e5627efa2ab1 13 | ja4s: t130200_1301_234ea6891581 14 | 15 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@tls12.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 192.168.133.129 8 | dst: 34.117.237.239 9 | src_port: 36372 10 | dst_port: 443 11 | tls_server_name: contile.services.mozilla.com 12 | ja4: t13d1715h2_5b57614c22b0_3d5424432f57 13 | 14 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@tls3.pcapng.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 7 6 | transport: tcp 7 | src: 192.168.1.169 8 | dst: 54.190.49.36 9 | src_port: 63248 10 | dst_port: 443 11 | tls_server_name: darksail.ai 12 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 13 | ja4s: t130200_1301_a56c5b993250 14 | ja4l_c: 16_128 15 | ja4l_s: 34615_35 16 | - stream: 8 17 | transport: tcp 18 | src: 192.168.1.169 19 | dst: 23.222.12.9 20 | src_port: 63249 21 | dst_port: 80 22 | ja4l_c: 14_128 23 | ja4l_s: 3181_57 24 | http: 25 | - ja4h: ge11nn07enus_3e3b55d61660_000000000000_000000000000 26 | - stream: 9 27 | transport: tcp 28 | src: 192.168.1.169 29 | dst: 54.190.49.36 30 | src_port: 63250 31 | dst_port: 443 32 | tls_server_name: darksail.ai 33 | ja4: t13d1516h2_8daaf6152771_9b887d9acb53 34 | ja4s: t130300_1301_0ee26285a86f 35 | ja4l_c: 13_128 36 | ja4l_s: 36549_35 37 | - stream: 10 38 | transport: tcp 39 | src: 192.168.1.169 40 | dst: 54.190.49.36 41 | src_port: 63251 42 | dst_port: 443 43 | tls_server_name: darksail.ai 44 | ja4: t13d1516h2_8daaf6152771_9b887d9acb53 45 | ja4s: t130300_1301_0ee26285a86f 46 | ja4l_c: 15_128 47 | ja4l_s: 34691_38 48 | - stream: 11 49 | transport: tcp 50 | src: 192.168.1.169 51 | dst: 104.21.234.234 52 | src_port: 63252 53 | dst_port: 443 54 | tls_server_name: rsms.me 55 | ja4: t13d1516h2_8daaf6152771_9b887d9acb53 56 | ja4s: t130300_1301_6bbbaf601ed8 57 | ja4l_c: 15_128 58 | ja4l_s: 2442_57 59 | - stream: 12 60 | transport: tcp 61 | src: 192.168.1.169 62 | dst: 54.190.49.36 63 | src_port: 63253 64 | dst_port: 443 65 | tls_server_name: darksail.ai 66 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 67 | ja4s: t130200_1301_a56c5b993250 68 | ja4l_c: 11_128 69 | ja4l_s: 36498_35 70 | - stream: 13 71 | transport: tcp 72 | src: 192.168.1.169 73 | dst: 54.190.49.36 74 | src_port: 63254 75 | dst_port: 443 76 | tls_server_name: darksail.ai 77 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 78 | ja4s: t130200_1301_a56c5b993250 79 | ja4l_c: 15_128 80 | ja4l_s: 33515_32 81 | - stream: 14 82 | transport: tcp 83 | src: 192.168.1.169 84 | dst: 54.190.49.36 85 | src_port: 63255 86 | dst_port: 443 87 | tls_server_name: darksail.ai 88 | ja4: t13d1516h2_8daaf6152771_e5627efa2ab1 89 | ja4s: t130200_1301_a56c5b993250 90 | ja4l_c: 11_128 91 | ja4l_s: 33738_33 92 | - stream: 21 93 | transport: udp 94 | src: 192.168.1.169 95 | dst: 172.253.122.95 96 | src_port: 62481 97 | dst_port: 443 98 | tls_server_name: fonts.googleapis.com 99 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 100 | ja4s: q130200_1301_234ea6891581 101 | ja4l_c: 59_128 102 | ja4l_s: 4213_59 103 | - stream: 22 104 | transport: udp 105 | src: 192.168.1.169 106 | dst: 104.21.234.234 107 | src_port: 61732 108 | dst_port: 443 109 | tls_server_name: rsms.me 110 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 111 | ja4s: q130200_1301_234ea6891581 112 | ja4l_c: 336_128 113 | ja4l_s: 5580_57 114 | - stream: 23 115 | transport: udp 116 | src: 192.168.1.169 117 | dst: 151.101.1.229 118 | src_port: 49791 119 | dst_port: 443 120 | tls_server_name: cdn.jsdelivr.net 121 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 122 | ja4s: q130200_1301_a56c5b993250 123 | ja4l_c: 40_128 124 | ja4l_s: 4455_58 125 | - stream: 24 126 | transport: udp 127 | src: 192.168.1.169 128 | dst: 104.17.24.14 129 | src_port: 56684 130 | dst_port: 443 131 | tls_server_name: cdnjs.cloudflare.com 132 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 133 | ja4s: q130200_1301_234ea6891581 134 | ja4l_c: 59_128 135 | ja4l_s: 3590_57 136 | - stream: 25 137 | transport: udp 138 | src: 192.168.1.169 139 | dst: 104.21.234.234 140 | src_port: 61884 141 | dst_port: 443 142 | tls_server_name: rsms.me 143 | ja4: q13d0311h3_55b375c5d22e_3512bcbbc9ec 144 | ja4s: q130300_1301_6bbbaf601ed8 145 | - stream: 28 146 | transport: udp 147 | src: 192.168.1.169 148 | dst: 142.251.111.94 149 | src_port: 58117 150 | dst_port: 443 151 | tls_server_name: fonts.gstatic.com 152 | ja4: q13d0310h3_55b375c5d22e_cd85d2d88918 153 | ja4s: q130200_1301_234ea6891581 154 | ja4l_c: 45_128 155 | ja4l_s: 3298_58 156 | 157 | -------------------------------------------------------------------------------- /pkg/ja4pcap/testdata/snapshots/ja4__insta@v6.pcap.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: ja4/src/lib.rs 3 | expression: output 4 | --- 5 | - stream: 0 6 | transport: tcp 7 | src: 3ffe:507:0:1:200:86ff:fe05:80da 8 | dst: 3ffe:501:410:0:2c0:dfff:fe47:33e 9 | src_port: 1022 10 | dst_port: 22 11 | ja4l_c: 271_64 12 | ja4l_s: 28494_61 13 | ja4ssh: 14 | - c20s12_c18s23_c11s2 15 | ssh_extras: 16 | hassh: null 17 | hassh_server: null 18 | ssh_protocol_client: SSH-1.5-1.2.26 19 | ssh_protocol_server: SSH-1.5-1.2.26 20 | encryption_algorithm: null 21 | 22 | -------------------------------------------------------------------------------- /pkg/metadata/context.go: -------------------------------------------------------------------------------- 1 | package metadata 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // contextKey is a value for use with context.WithValue. It's used as 8 | // a pointer so it fits in an interface{} without allocation. 9 | type contextKey struct { 10 | name string 11 | } 12 | 13 | func (k *contextKey) String() string { return "fingerproxy context value " + k.name } 14 | 15 | var ( 16 | FingerproxyContextKey = &contextKey{"fingerproxy-metadata"} 17 | ) 18 | 19 | func NewContext(ctx context.Context) (context.Context, *Metadata) { 20 | md := &Metadata{} 21 | newCtx := context.WithValue(ctx, FingerproxyContextKey, md) 22 | return newCtx, md 23 | } 24 | 25 | func FromContext(ctx context.Context) (*Metadata, bool) { 26 | data, ok := ctx.Value(FingerproxyContextKey).(*Metadata) 27 | return data, ok 28 | } 29 | -------------------------------------------------------------------------------- /pkg/metadata/http2.go: -------------------------------------------------------------------------------- 1 | package metadata 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math" 7 | ) 8 | 9 | // https://github.com/golang/net/blob/5a444b4f2fe893ea00f0376da46aa5376c3f3e28/http2/http2.go#L112-L119 10 | type Setting struct { 11 | Id uint16 12 | Val uint32 13 | } 14 | 15 | // https://github.com/golang/net/blob/5a444b4f2fe893ea00f0376da46aa5376c3f3e28/http2/frame.go#L1142-L1156 16 | type Priority struct { 17 | StreamId uint32 18 | StreamDep uint32 19 | Exclusive bool 20 | Weight uint8 21 | } 22 | 23 | // https://github.com/golang/net/blob/5a444b4f2fe893ea00f0376da46aa5376c3f3e28/http2/hpack/hpack.go#L36-L42 24 | type HeaderField struct { 25 | Name, Value string 26 | Sensitive bool 27 | } 28 | 29 | type HTTP2FingerprintingFrames struct { 30 | // Data from SETTINGS frame 31 | Settings []Setting 32 | 33 | // Increment of WINDOW_UPDATE frame 34 | WindowUpdateIncrement uint32 35 | 36 | // PRIORITY frame(s) 37 | Priorities []Priority 38 | 39 | // HEADERS frame 40 | Headers []HeaderField 41 | } 42 | 43 | func (f *HTTP2FingerprintingFrames) String() string { 44 | return f.Marshal(math.MaxUint) 45 | } 46 | 47 | // TODO: add tests 48 | func (f *HTTP2FingerprintingFrames) Marshal(maxPriorityFrames uint) string { 49 | var buf bytes.Buffer 50 | 51 | // SETTINGS frame 52 | for i, s := range f.Settings { 53 | if i != 0 { 54 | // Multiple settings are concatenated using a semicolon (;) according to the order of their appearance. 55 | buf.WriteString(";") 56 | } 57 | // S[...] stands for a SETTINGS parameter and its value in the form of Key:Value. 58 | buf.WriteString(fmt.Sprintf("%d:%d", s.Id, s.Val)) 59 | } 60 | 61 | buf.WriteString("|") 62 | 63 | // WINDOW_UPDATE frame 64 | // ‘00’ if the frame is not present 65 | buf.WriteString(fmt.Sprintf("%02d|", f.WindowUpdateIncrement)) 66 | 67 | // PRIORITY frame 68 | if l := len(f.Priorities); uint(l) < maxPriorityFrames { 69 | maxPriorityFrames = uint(l) 70 | } 71 | if maxPriorityFrames == 0 { 72 | // If this feature does not exist, the value should be ‘0’. 73 | buf.WriteString("0|") 74 | } else { 75 | for i, p := range f.Priorities[:maxPriorityFrames] { 76 | if i != 0 { 77 | // Multiple priority frames are concatenated by a comma (,). 78 | buf.WriteString(",") 79 | } 80 | // StreamID:Exclusivity_Bit:Dependant_StreamID:Weight 81 | buf.WriteString(fmt.Sprintf("%d:", p.StreamId)) 82 | if p.Exclusive { 83 | buf.WriteString("1:") 84 | } else { 85 | buf.WriteString("0:") 86 | } 87 | // "Add one to the value to obtain a weight between 1 and 256." 88 | // Quoted from https://httpwg.org/specs/rfc7540.html#PRIORITY 89 | buf.WriteString(fmt.Sprintf("%d:%d", p.StreamDep, int(p.Weight)+1)) 90 | } 91 | buf.WriteString("|") 92 | } 93 | 94 | // HEADERS frame 95 | wrotePseudoHeader := false 96 | for _, h := range f.Headers { 97 | // filter only pseudo headers which starts with a colon 98 | if len(h.Name) >= 2 && h.Name[0] == ':' { 99 | if wrotePseudoHeader { 100 | buf.WriteString(",") 101 | } 102 | wrotePseudoHeader = true 103 | buf.WriteByte(h.Name[1]) 104 | } 105 | } 106 | 107 | return buf.String() 108 | } 109 | -------------------------------------------------------------------------------- /pkg/metadata/metadata.go: -------------------------------------------------------------------------------- 1 | // Package `metadata` has a struct that stores information captured by 2 | // `proxyserver`. Package `fingerprint` uses these information to create 3 | // fingerprints. 4 | package metadata 5 | 6 | import "crypto/tls" 7 | 8 | // Metadata is the data we captured from the connection for fingerprinting. 9 | // Currently only TLS ClientHello and certain HTTP2 frames included, more can 10 | // be added in the future. 11 | type Metadata struct { 12 | // ClientHelloRecord is the raw TLS ClientHello bytes that 13 | // include TLS record header and handshake header 14 | ClientHelloRecord []byte 15 | 16 | // ConnectionState represents the TLS connection state 17 | ConnectionState tls.ConnectionState 18 | 19 | // HTTP2Frames includes certain HTTP2 frames data 20 | HTTP2Frames HTTP2FingerprintingFrames 21 | } 22 | -------------------------------------------------------------------------------- /pkg/proxyserver/proxyserver_test.go: -------------------------------------------------------------------------------- 1 | package proxyserver 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "testing" 7 | ) 8 | 9 | func TestIsNetworkOrClientError(t *testing.T) { 10 | // https://github.com/golang/go/blob/release-branch.go1.22/src/crypto/tls/alert.go#L77 11 | alert := fmt.Errorf("unknown certificate authority") 12 | // https://github.com/golang/go/blob/release-branch.go1.22/src/crypto/tls/conn.go#L724 13 | err := setErrorLocked(&net.OpError{Op: "remote error", Err: alert}) 14 | if isNetworkOrClientError(err) == false { 15 | t.Error("expected tls alert record is client error, got false") 16 | } 17 | 18 | if isNetworkOrClientError(fmt.Errorf("some random error")) { 19 | t.Error("expected random error is not client error, got true") 20 | } 21 | } 22 | 23 | type permanentError struct { 24 | err net.Error 25 | } 26 | 27 | func (e *permanentError) Error() string { return e.err.Error() } 28 | func (e *permanentError) Unwrap() error { return e.err } 29 | func (e *permanentError) Timeout() bool { return e.err.Timeout() } 30 | func (e *permanentError) Temporary() bool { return false } 31 | 32 | func setErrorLocked(err error) error { 33 | if e, ok := err.(net.Error); ok { 34 | return &permanentError{err: e} 35 | } else { 36 | return err 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/reverseproxy/handler.go: -------------------------------------------------------------------------------- 1 | // Package `reverseproxy` forwards the requests to backends. It gets 2 | // additional request headers from `header_injectors`, and adds to the 3 | // forwarding request to downstream. 4 | package reverseproxy 5 | 6 | import ( 7 | "log" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "strings" 12 | ) 13 | 14 | type HTTPHandler struct { 15 | // required, internal reverse proxy that forwards the requests 16 | reverseProxy *httputil.ReverseProxy 17 | 18 | // required, the URL that requests will be forwarding to 19 | To *url.URL 20 | 21 | // optional, preserve the host in outbound requests 22 | PreserveHost bool 23 | 24 | // optional, but in fact required, injecting fingerprint headers to outbound requests 25 | HeaderInjectors []HeaderInjector 26 | 27 | // optional, if IsProbeRequest returns true, handler will respond with 28 | // a HTTP 200 OK instead of forwarding requests, useful for kubernetes 29 | // liveness/readiness probes. defaults to nil, which disables this behavior 30 | IsProbeRequest func(*http.Request) bool 31 | } 32 | 33 | const ( 34 | ProbeStatusCode = http.StatusOK 35 | ProbeResponse = "OK" 36 | ) 37 | 38 | // NewHTTPHandler creates an HTTP handler, changes `reverseProxy.Rewrite` to support request 39 | // header injection, then assigns `reverseProxy` to the handler which proxies requests to backend 40 | func NewHTTPHandler(to *url.URL, reverseProxy *httputil.ReverseProxy, headerInjectors []HeaderInjector) *HTTPHandler { 41 | f := &HTTPHandler{ 42 | To: to, 43 | reverseProxy: reverseProxy, 44 | HeaderInjectors: headerInjectors, 45 | } 46 | 47 | f.reverseProxy.Rewrite = f.rewriteFunc 48 | return f 49 | } 50 | 51 | func (f *HTTPHandler) rewriteFunc(r *httputil.ProxyRequest) { 52 | r.SetURL(f.To) 53 | r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 54 | r.SetXForwarded() 55 | 56 | if f.PreserveHost { 57 | r.Out.Host = r.In.Host 58 | } 59 | 60 | for _, hj := range f.HeaderInjectors { 61 | k := hj.GetHeaderName() 62 | if v, err := hj.GetHeaderValue(r.In); err != nil { 63 | f.logf("get header %s value for %s failed: %s", k, r.In.RemoteAddr, err) 64 | } else if v != "" { // skip empty header values 65 | r.Out.Header.Set(k, v) 66 | } 67 | } 68 | } 69 | 70 | func (f *HTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 71 | if f.IsProbeRequest != nil && f.IsProbeRequest(req) { 72 | w.WriteHeader(ProbeStatusCode) 73 | w.Write([]byte(ProbeResponse)) 74 | return 75 | } 76 | f.reverseProxy.ServeHTTP(w, req) 77 | } 78 | 79 | func IsKubernetesProbeRequest(r *http.Request) bool { 80 | // https://github.com/kubernetes/kubernetes/blob/656cb1028ea5af837e69b5c9c614b008d747ab63/pkg/probe/http/request.go#L91 81 | return strings.HasPrefix(r.UserAgent(), "kube-probe/") 82 | } 83 | 84 | func (f *HTTPHandler) logf(format string, args ...any) { 85 | if f.reverseProxy.ErrorLog != nil { 86 | f.reverseProxy.ErrorLog.Printf(format, args...) 87 | } else { 88 | log.Printf(format, args...) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/reverseproxy/handler_test.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | type dummyResponseWriter struct { 14 | buf bytes.Buffer 15 | code int 16 | header http.Header 17 | } 18 | 19 | func (w *dummyResponseWriter) Header() http.Header { 20 | if w.header == nil { 21 | w.header = http.Header{} 22 | } 23 | return w.header 24 | } 25 | func (w *dummyResponseWriter) WriteHeader(statusCode int) { w.code = statusCode } 26 | func (w *dummyResponseWriter) Write(b []byte) (int, error) { return w.buf.Write(b) } 27 | 28 | const ( 29 | dummyRemoteIP = "1.1.1.1" 30 | dummyForwardedFor = "172.17.0.1" 31 | ) 32 | 33 | func dummyRequest(t *testing.T) *http.Request { 34 | t.Helper() 35 | req, err := http.NewRequest("GET", "https://dummy-host/anything?show_env=1", nil) 36 | req.RemoteAddr = dummyRemoteIP + ":30000" 37 | req.Header.Set("User-Agent", "dummy") 38 | req.Header.Set("X-Forwarded-For", dummyForwardedFor) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | return req 43 | } 44 | 45 | func dummyURL(t *testing.T) *url.URL { 46 | url, err := url.Parse("https://httpbin.org") 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | return url 51 | } 52 | 53 | func TestKubernetesLivenessProbe(t *testing.T) { 54 | handler := NewHTTPHandler(dummyURL(t), &httputil.ReverseProxy{}, nil) 55 | handler.IsProbeRequest = IsKubernetesProbeRequest 56 | 57 | req := dummyRequest(t) 58 | req.Header.Set("User-Agent", "kube-probe/1.26") 59 | 60 | w := &dummyResponseWriter{} 61 | handler.ServeHTTP(w, req) 62 | 63 | if w.buf.String() != ProbeResponse { 64 | t.Fatalf("expected response %s, actual %s", ProbeResponse, w.buf.String()) 65 | } 66 | if w.code != ProbeStatusCode { 67 | t.Fatalf("expected status code %d, actual %d", ProbeStatusCode, w.code) 68 | } 69 | } 70 | 71 | type dummyHeaderInjector struct{} 72 | 73 | func (i *dummyHeaderInjector) GetHeaderName() string { return "dummy" } 74 | func (i *dummyHeaderInjector) GetHeaderValue(req *http.Request) (string, error) { 75 | return "dummy-value", nil 76 | } 77 | 78 | func TestInjectHeader(t *testing.T) { 79 | hj := &dummyHeaderInjector{} 80 | handler := NewHTTPHandler(dummyURL(t), &httputil.ReverseProxy{}, []HeaderInjector{hj}) 81 | 82 | w := &dummyResponseWriter{} 83 | handler.ServeHTTP(w, dummyRequest(t)) 84 | 85 | j := struct { 86 | Headers struct { 87 | Dummy string 88 | Host string 89 | } 90 | }{} 91 | 92 | t.Log(w.buf.String()) 93 | 94 | err := json.Unmarshal(w.buf.Bytes(), &j) 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | 99 | if j.Headers.Host != "httpbin.org" { 100 | t.Fatalf("expected header value %s, actual %s", "httpbin.org", j.Headers.Host) 101 | } 102 | 103 | if j.Headers.Dummy != "dummy-value" { 104 | t.Fatalf("expected header value %s, actual %s", "dummy-value", j.Headers.Dummy) 105 | } 106 | } 107 | 108 | func TestPreserveHost(t *testing.T) { 109 | handler := NewHTTPHandler(dummyURL(t), &httputil.ReverseProxy{}, nil) 110 | handler.PreserveHost = true 111 | 112 | w := &dummyResponseWriter{} 113 | handler.ServeHTTP(w, dummyRequest(t)) 114 | 115 | j := struct { 116 | Headers struct { 117 | Host string 118 | } 119 | }{} 120 | 121 | t.Log(w.buf.String()) 122 | 123 | err := json.Unmarshal(w.buf.Bytes(), &j) 124 | if err != nil { 125 | t.Fatal(err) 126 | } 127 | 128 | if j.Headers.Host != "dummy-host" { 129 | t.Fatalf("expected header value %s, actual %s", "dummy-host", j.Headers.Host) 130 | } 131 | } 132 | 133 | func TestAppendForwardHeader(t *testing.T) { 134 | handler := NewHTTPHandler(dummyURL(t), &httputil.ReverseProxy{}, nil) 135 | 136 | w := &dummyResponseWriter{} 137 | handler.ServeHTTP(w, dummyRequest(t)) 138 | 139 | j := struct { 140 | Headers struct { 141 | XForwardedFor string `json:"x-forwarded-for"` 142 | } 143 | }{} 144 | 145 | t.Log(w.buf.String()) 146 | 147 | err := json.Unmarshal(w.buf.Bytes(), &j) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | 152 | if !strings.HasPrefix(j.Headers.XForwardedFor, "172.17.0.1, 1.1.1.1") { 153 | t.Fatalf("expected header value %s, actual %s", "dummy-host", j.Headers.XForwardedFor) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /pkg/reverseproxy/header_injector.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import "net/http" 4 | 5 | type HeaderInjector interface { 6 | GetHeaderName() string 7 | 8 | GetHeaderValue(req *http.Request) (string, error) 9 | } 10 | -------------------------------------------------------------------------------- /pkg/sync-http2-pkg.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S bash -exuo pipefail 2 | 3 | # 4 | # Sync upstream http2 package. 5 | # 6 | 7 | cd $(dirname "$0") 8 | 9 | TAG=v0.33.0 10 | TAG_ARCHIVE_FILENAME=$TAG.tar.gz 11 | LOCAL_ARCHIVE_FILENAME=/tmp/$TAG_ARCHIVE_FILENAME 12 | 13 | wget -O $LOCAL_ARCHIVE_FILENAME https://github.com/golang/net/archive/refs/tags/$TAG_ARCHIVE_FILENAME 14 | 15 | TMP_SRCDIR=$(mktemp -d) 16 | TARBALL_ROOTDIR=$(tar tf $LOCAL_ARCHIVE_FILENAME | head -n1) 17 | 18 | tar xzf $LOCAL_ARCHIVE_FILENAME --directory $TMP_SRCDIR 19 | 20 | rsync -avhW --no-compress --delete $TMP_SRCDIR/$TARBALL_ROOTDIR/http2/ ./http2/ 21 | rsync -avhW --no-compress $TMP_SRCDIR/$TARBALL_ROOTDIR/LICENSE ./http2/ 22 | 23 | rm -rf $TMP_SRCDIR 24 | -------------------------------------------------------------------------------- /testdata/gencert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | 3 | SAN=example.com 4 | 5 | openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 \ 6 | -nodes -keyout tls.key -out tls.crt -subj "/CN=$SAN" \ 7 | -addext "subjectAltName=DNS:$SAN,DNS:*.$SAN,IP:127.0.0.1" 8 | --------------------------------------------------------------------------------