-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add soaxreport tool for ECH testing via SOAX proxies
#5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
00c9f8d
32ed53a
1972fe7
ac756fb
55170b9
33397dd
07a6740
d7f5a50
e6fa052
e978f1e
f0557b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package curl | ||
|
|
||
| // ExitCodeName returns the human readable name for a curl exit code. | ||
| func ExitCodeName(code int) string { | ||
| if code == 0 { | ||
| return "OK" | ||
| } | ||
| if name, ok := exitCodeNames[code]; ok { | ||
| return name | ||
| } | ||
| return "UNKNOWN_ERROR" | ||
| } | ||
|
|
||
| var exitCodeNames = map[int]string{ | ||
| 1: "CURLE_UNSUPPORTED_PROTOCOL", | ||
| 2: "CURLE_FAILED_INIT", | ||
| 3: "CURLE_URL_MALFORMAT", | ||
| 4: "CURLE_NOT_BUILT_IN", | ||
| 5: "CURLE_COULDNT_RESOLVE_PROXY", | ||
| 6: "CURLE_COULDNT_RESOLVE_HOST", | ||
| 7: "CURLE_COULDNT_CONNECT", | ||
| 8: "CURLE_WEIRD_SERVER_REPLY", | ||
| 9: "CURLE_REMOTE_ACCESS_DENIED", | ||
| 11: "CURLE_FTP_WEIRD_PASV_REPLY", | ||
| 13: "CURLE_FTP_WEIRD_227_FORMAT", | ||
| 14: "CURLE_FTP_CANT_GET_HOST", | ||
| 15: "CURLE_FTP_CANT_RECONNECT", | ||
| 17: "CURLE_FTP_COULDNT_SET_TYPE", | ||
| 18: "CURLE_PARTIAL_FILE", | ||
| 19: "CURLE_FTP_COULDNT_RETR_FILE", | ||
| 21: "CURLE_QUOTE_ERROR", | ||
| 22: "CURLE_HTTP_RETURNED_ERROR", | ||
| 23: "CURLE_WRITE_ERROR", | ||
| 25: "CURLE_UPLOAD_FAILED", | ||
| 26: "CURLE_READ_ERROR", | ||
| 27: "CURLE_OUT_OF_MEMORY", | ||
| 28: "CURLE_OPERATION_TIMEDOUT", | ||
| 30: "CURLE_FTP_PORT_FAILED", | ||
| 31: "CURLE_FTP_COULDNT_USE_REST", | ||
| 33: "CURLE_RANGE_ERROR", | ||
| 34: "CURLE_HTTP_POST_ERROR", | ||
| 35: "CURLE_SSL_CONNECT_ERROR", | ||
| 36: "CURLE_BAD_DOWNLOAD_RESUME", | ||
| 37: "CURLE_FILE_COULDNT_READ_FILE", | ||
| 38: "CURLE_LDAP_CANNOT_BIND", | ||
| 39: "CURLE_LDAP_SEARCH_FAILED", | ||
| 41: "CURLE_FUNCTION_NOT_FOUND", | ||
| 42: "CURLE_ABORTED_BY_CALLBACK", | ||
| 43: "CURLE_BAD_FUNCTION_ARGUMENT", | ||
| 45: "CURLE_INTERFACE_FAILED", | ||
| 47: "CURLE_TOO_MANY_REDIRECTS", | ||
| 48: "CURLE_UNKNOWN_OPTION", | ||
| 49: "CURLE_TELNET_OPTION_SYNTAX", | ||
| 51: "CURLE_PEER_FAILED_VERIFICATION", | ||
| 52: "CURLE_GOT_NOTHING", | ||
| 53: "CURLE_SSL_ENGINE_NOTFOUND", | ||
| 54: "CURLE_SSL_ENGINE_SETFAILED", | ||
| 55: "CURLE_SEND_ERROR", | ||
| 56: "CURLE_RECV_ERROR", | ||
| 58: "CURLE_SSL_CERTPROBLEM", | ||
| 59: "CURLE_SSL_CIPHER", | ||
| 60: "CURLE_SSL_CACERT", | ||
| 61: "CURLE_BAD_CONTENT_ENCODING", | ||
| 62: "CURLE_LDAP_INVALID_URL", | ||
| 63: "CURLE_FILESIZE_EXCEEDED", | ||
| 64: "CURLE_USE_SSL_FAILED", | ||
| 65: "CURLE_SEND_FAIL_REWIND", | ||
| 66: "CURLE_SSL_ENGINE_INITFAILED", | ||
| 67: "CURLE_LOGIN_DENIED", | ||
| 68: "CURLE_TFTP_NOTFOUND", | ||
| 69: "CURLE_TFTP_PERM", | ||
| 70: "CURLE_REMOTE_DISK_FULL", | ||
| 71: "CURLE_TFTP_ILLEGAL", | ||
| 72: "CURLE_TFTP_UNKNOWNID", | ||
| 73: "CURLE_REMOTE_FILE_EXISTS", | ||
| 74: "CURLE_TFTP_NOSUCHUSER", | ||
| 75: "CURLE_CONV_FAILED", | ||
| 76: "CURLE_CONV_REQD", | ||
| 77: "CURLE_SSL_CACERT_BADFILE", | ||
| 78: "CURLE_REMOTE_FILE_NOT_FOUND", | ||
| 79: "CURLE_SSH", | ||
| 80: "CURLE_SSL_SHUTDOWN_FAILED", | ||
| 81: "CURLE_AGAIN", | ||
| 82: "CURLE_SSL_CRL_BADFILE", | ||
| 83: "CURLE_SSL_ISSUER_ERROR", | ||
| 84: "CURLE_FTP_PRET_FAILED", | ||
| 85: "CURLE_RTSP_CSEQ_ERROR", | ||
| 86: "CURLE_RTSP_SESSION_ERROR", | ||
| 87: "CURLE_FTP_BAD_FILE_LIST", | ||
| 88: "CURLE_CHUNK_FAILED", | ||
| 89: "CURLE_NO_CONNECTION_AVAILABLE", | ||
| 90: "CURLE_SSL_PINNEDPUBKEYNOTMATCH", | ||
| 91: "CURLE_SSL_INVALIDCERTSTATUS", | ||
| 92: "CURLE_HTTP2_STREAM", | ||
| 93: "CURLE_RECURSIVE_API_CALL", | ||
| 94: "CURLE_AUTH_ERROR", | ||
| 95: "CURLE_HTTP3", | ||
| 96: "CURLE_QUIC_CONNECT_ERROR", | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,170 @@ | ||||
| // Copyright 2026 Google LLC | ||||
| // | ||||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| // you may not use this file except in compliance with the License. | ||||
| // You may obtain a copy of the License at | ||||
| // | ||||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||||
| // | ||||
| // Unless required by applicable law or agreed to in writing, software | ||||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| // See the License for the specific language governing permissions and | ||||
| // limitations under the License. | ||||
|
|
||||
| package curl | ||||
|
|
||||
| import ( | ||||
| "fmt" | ||||
| "os" | ||||
| "os/exec" | ||||
| "path/filepath" | ||||
| "strconv" | ||||
| "strings" | ||||
| "time" | ||||
| ) | ||||
|
|
||||
| // Runner handles the execution of a specific curl binary. | ||||
| // It manages environment setup (e.g., LD_LIBRARY_PATH) required for custom builds. | ||||
| type Runner struct { | ||||
| curlPath string | ||||
| libPath string | ||||
| } | ||||
|
|
||||
| // Args defines the execution parameters for a single curl run. | ||||
| type Args struct { | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like we are adding a translation layer here that is not necessary. There's also a lot of duplication with ech-research/greasereport/main.go Line 139 in 933d4dc
I think we can extract runTest instead. Doesn't it work for both reports? That will also make it clearer that we are running the same test! |
||||
| // Proxy specifies the proxy URL to use. | ||||
| // Corresponds to the "--proxy" flag. | ||||
| Proxy string | ||||
|
|
||||
| // ProxyHeaders specifies custom headers to send to the proxy. | ||||
| // Each string should be in "Key: Value" format. | ||||
| // Corresponds to the "--proxy-header" flag. | ||||
| ProxyHeaders []string | ||||
|
|
||||
| // ECH specifies the Encrypted ClientHello mode. | ||||
| // Corresponds to the "--ech" flag. | ||||
| // Use ECHGrease, ECHTrue, ECHFalse constants. | ||||
| // If empty (ECHNone), the flag is omitted. | ||||
| ECH ECHMode | ||||
|
|
||||
| // Verbose enables verbose output. | ||||
| // If true, adds "-v". | ||||
| // If false, adds "-s" (silent mode) by default to keep output clean. | ||||
| Verbose bool | ||||
|
|
||||
| // Timeout sets the maximum time allowed for the transfer. | ||||
| // Corresponds to the "--max-time" flag. | ||||
| // If 0, no timeout is set. | ||||
| Timeout time.Duration | ||||
|
|
||||
| // MeasureStats enables capturing performance metrics using curl's -w flag. | ||||
| // If true, Stats will be populated in the Result. | ||||
| MeasureStats bool | ||||
| } | ||||
|
|
||||
| // ECHMode defines the available Encrypted ClientHello modes for curl. | ||||
| type ECHMode string | ||||
|
|
||||
| const ( | ||||
| // ECHGrease enables ECH GREASE mode ("--ech grease"). | ||||
| ECHGrease ECHMode = "grease" | ||||
| // ECHTrue enables ECH ("--ech true"). | ||||
| ECHTrue ECHMode = "true" | ||||
| // ECHFalse disables ECH ("--ech false"). | ||||
| ECHFalse ECHMode = "false" | ||||
| // ECHNone indicates that the --ech flag should not be sent. | ||||
| ECHNone ECHMode = "" | ||||
| ) | ||||
|
|
||||
| // Result represents the raw outcome of a curl execution. | ||||
| type Result struct { | ||||
| // ExitCode is the exit status of the curl process. | ||||
| // 0 indicates success. See ExitCodeName for error name mapping. | ||||
| ExitCode int | ||||
|
|
||||
| // Stdout contains the standard output of the curl command. | ||||
| Stdout string | ||||
|
|
||||
| // Stderr contains the standard error of the curl command. | ||||
| // In verbose mode, this contains debug information and headers. | ||||
| Stderr string | ||||
|
|
||||
| // Stats contains performance metrics if MeasureStats was enabled. | ||||
| Stats Stats | ||||
| } | ||||
|
|
||||
| // NewRunner creates a new Runner for the specified curl binary. | ||||
| // It automatically detects the associated library path (bin/curl -> lib/) | ||||
| // to ensure shared libraries are found. | ||||
| func NewRunner(curlPath string) *Runner { | ||||
| r := &Runner{curlPath: curlPath} | ||||
|
|
||||
| binDir := filepath.Dir(curlPath) | ||||
| libDir := filepath.Join(filepath.Dir(binDir), "lib") | ||||
| if libStat, err := os.Stat(libDir); err == nil && libStat.IsDir() { | ||||
| r.libPath = libDir | ||||
| } | ||||
|
|
||||
| return r | ||||
| } | ||||
|
|
||||
| // Run executes curl with the provided arguments and returns the result. | ||||
| func (r *Runner) Run(url string, args Args) (*Result, error) { | ||||
| var cmdArgs []string | ||||
|
|
||||
| if args.Verbose { | ||||
| cmdArgs = append(cmdArgs, "-v") | ||||
| } else { | ||||
| cmdArgs = append(cmdArgs, "-s") | ||||
| } | ||||
|
|
||||
| if args.Timeout > 0 { | ||||
| cmdArgs = append(cmdArgs, "--max-time", strconv.FormatFloat(args.Timeout.Seconds(), 'f', -1, 64)) | ||||
| } | ||||
|
|
||||
| if args.Proxy != "" { | ||||
| cmdArgs = append(cmdArgs, "--proxy", args.Proxy) | ||||
| } | ||||
|
|
||||
| for _, h := range args.ProxyHeaders { | ||||
| cmdArgs = append(cmdArgs, "--proxy-header", h) | ||||
| } | ||||
|
|
||||
| if args.ECH != ECHNone { | ||||
| cmdArgs = append(cmdArgs, "--ech", string(args.ECH)) | ||||
| } | ||||
|
|
||||
| if args.MeasureStats { | ||||
| cmdArgs = append(cmdArgs, "-w", statsFormat) | ||||
| } | ||||
|
|
||||
| cmdArgs = append(cmdArgs, url) | ||||
| cmd := exec.Command(r.curlPath, cmdArgs...) | ||||
| if r.libPath != "" { | ||||
| cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+r.libPath) | ||||
| } | ||||
|
|
||||
| var stdout, stderr strings.Builder | ||||
| cmd.Stdout = &stdout | ||||
| cmd.Stderr = &stderr | ||||
|
|
||||
| result := &Result{} | ||||
| err := cmd.Run() | ||||
| result.Stdout = stdout.String() | ||||
| result.Stderr = stderr.String() | ||||
|
|
||||
| if args.MeasureStats { | ||||
| result.Stats = parseStats(result.Stdout) | ||||
| } | ||||
|
|
||||
| if err != nil { | ||||
| if exitErr, ok := err.(*exec.ExitError); ok { | ||||
| result.ExitCode = exitErr.ExitCode() | ||||
| } else { | ||||
| return result, fmt.Errorf("failed to execute curl: %w", err) | ||||
| } | ||||
| } | ||||
|
|
||||
| return result, nil | ||||
| } | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package curl | ||
|
|
||
| import ( | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| // Stats captures timing and status metrics from a curl execution. | ||
| type Stats struct { | ||
| // DNSLookupTimestamp is the cumulative time from the start until the name | ||
| // lookup is completed (time_namelookup). | ||
| DNSLookupTimestamp time.Duration | ||
|
|
||
| // TCPConnectTimestamp is the cumulative time from the start until the TCP | ||
| // connection is completed (time_connect). | ||
| TCPConnectTimestamp time.Duration | ||
|
|
||
| // TLSConnectTimestamp is the cumulative time from the start until the | ||
| // SSL/TLS handshake is completed (time_appconnect). | ||
| TLSConnectTimestamp time.Duration | ||
|
|
||
| // ServerResponseTimestamp is the cumulative time from the start until the | ||
| // first byte is received (time_starttransfer). | ||
| ServerResponseTimestamp time.Duration | ||
|
|
||
| // TotalTimeTimestamp is the total time from the start until the operation is | ||
| // fully completed (time_total). | ||
| TotalTimeTimestamp time.Duration | ||
|
|
||
| // HTTPStatus is the HTTP response code (http_code). | ||
| HTTPStatus int | ||
| } | ||
|
|
||
| const ( | ||
| // statsPrefix is the delimiter used to identify the statistics block in the output. | ||
| statsPrefix = "\n|||CURL_STATS|||\t" | ||
|
|
||
| // statsFormat is the format string passed to curl's -w flag. | ||
| statsFormat = statsPrefix + | ||
| "dnslookup:%{time_namelookup}," + | ||
| "tcpconnect:%{time_connect}," + | ||
| "tlsconnect:%{time_appconnect}," + | ||
| "servertime:%{time_starttransfer}," + | ||
| "total:%{time_total}," + | ||
| "httpstatus:%{http_code}" | ||
| ) | ||
|
|
||
| // parseStats extracts Stats from the curl output by looking for the statsPrefix. | ||
| func parseStats(stdout string) Stats { | ||
| var s Stats | ||
| idx := strings.LastIndex(stdout, statsPrefix) | ||
| if idx == -1 { | ||
| return s | ||
| } | ||
|
|
||
| raw := strings.TrimSpace(stdout[idx+len(statsPrefix):]) | ||
| for part := range strings.SplitSeq(raw, ",") { | ||
| kv := strings.Split(part, ":") | ||
| if len(kv) != 2 { | ||
| continue | ||
| } | ||
|
|
||
| key, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) | ||
| switch key { | ||
| case "dnslookup": | ||
| s.DNSLookupTimestamp = parseDuration(val) | ||
| case "tcpconnect": | ||
| s.TCPConnectTimestamp = parseDuration(val) | ||
| case "tlsconnect": | ||
| s.TLSConnectTimestamp = parseDuration(val) | ||
| case "servertime": | ||
| s.ServerResponseTimestamp = parseDuration(val) | ||
| case "total": | ||
| s.TotalTimeTimestamp = parseDuration(val) | ||
| case "httpstatus": | ||
| s.HTTPStatus, _ = strconv.Atoi(val) | ||
| } | ||
| } | ||
| return s | ||
| } | ||
|
|
||
| // parseDuration converts a seconds-based float string to time.Duration. | ||
| func parseDuration(s string) time.Duration { | ||
| f, _ := strconv.ParseFloat(s, 64) | ||
| return time.Duration(f * float64(time.Second)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please dedupe
ech-research/greasereport/main.go
Line 52 in 933d4dc