├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | chromedp-proxy 2 | chromedp-proxy.exe 3 | logs/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2023 Kenneth Shaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About chromedp-proxy 2 | 3 | `chromedp-proxy` is a simple command-line tool to proxy and log [Chrome 4 | DevTools Protocol][devtools-protocol] sessions sent from a CDP client to a CDP 5 | browser session. `chromedp-proxy` captures and (by default) logs all of the 6 | WebSocket messages sent during a CDP session between a remote and local 7 | endpoint, and can be used to expose a CDP browser listening on localhost to a 8 | remote endpoint. 9 | 10 | `chromedp-proxy` is mainly used to capture and debug the wireline protocol sent 11 | from DevTools/Selenium/Puppeteer to Chrome/Chromium/headless_shell/etc. It was 12 | originally written for debugging wireline problems/issues with the 13 | [`chromedp`][chromedp] project. 14 | 15 | ## Installing 16 | 17 | Install in the usual Go way: 18 | 19 | ```sh 20 | $ go get -u github.com/chromedp/chromedp-proxy 21 | ``` 22 | 23 | ## Using 24 | 25 | By default, `chromedp-proxy` listens on `localhost:9223` and proxies 26 | requests to/from `localhost:9222`: 27 | 28 | ```sh 29 | $ chromedp-proxy 30 | ``` 31 | 32 | `chromedp-proxy` can also be used to expose a local Chrome instance on an 33 | external address/port: 34 | 35 | ```sh 36 | $ chromedp-proxy -l 192.168.1.10:9222 37 | ``` 38 | 39 | By default, `chromedp-proxy` logs to both `stdout` and to 40 | `$PWD/logs/cdp-.log`, but that can be changed through flags: 41 | 42 | ```sh 43 | # only log to stdout 44 | $ chromedp-proxy -n 45 | 46 | # or only log to stdout by specifying an empty log name 47 | $ chromedp-proxy -log '' 48 | 49 | # log to /var/log/cdp/session-.log 50 | $ chromedp-proxy -log '/var/log/cdp/session-%s.log' 51 | ``` 52 | 53 | ### Command-line options 54 | 55 | ```sh 56 | $ ./chromedp-proxy -help 57 | Usage of ./chromedp-proxy: 58 | -l string 59 | listen address (default "localhost:9223") 60 | -log string 61 | log file mask (default "logs/cdp-%s.log") 62 | -n disable logging to file 63 | -r string 64 | remote address (default "localhost:9222") 65 | ``` 66 | 67 | [devtools-protocol]: https://chromedevtools.github.io/devtools-protocol/ 68 | [chromedp]: https://github.com/chromedp 69 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chromedp/chromedp-proxy 2 | 3 | go 1.22 4 | 5 | require github.com/gorilla/websocket v1.5.3 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // chromedp-proxy provides a cli utility that will proxy requests from a Chrome 2 | // DevTools Protocol client to a browser instance. 3 | // 4 | // chromedp-proxy is particularly useful for recording events/data from 5 | // Selenium (ChromeDriver), Chrome DevTools in the browser, or for debugging 6 | // remote application instances compatible with the devtools protocol. 7 | // 8 | // Please see README.md for more information on using chromedp-proxy. 9 | package main 10 | 11 | import ( 12 | "context" 13 | "encoding/json" 14 | "flag" 15 | "fmt" 16 | "io" 17 | "io/ioutil" 18 | "log" 19 | "net/http" 20 | "net/http/httputil" 21 | "net/url" 22 | "os" 23 | "path" 24 | "regexp" 25 | "strings" 26 | 27 | "github.com/gorilla/websocket" 28 | ) 29 | 30 | func main() { 31 | listen := flag.String("l", "localhost:9223", "listen address") 32 | remote := flag.String("r", "localhost:9222", "remote address") 33 | noLog := flag.Bool("n", false, "disable logging to file") 34 | logMask := flag.String("log", "logs/cdp-%s.log", "log file mask") 35 | flag.Parse() 36 | if err := run(context.Background(), *listen, *remote, *noLog, *logMask); err != nil { 37 | fmt.Fprintf(os.Stderr, "error: %v\n", err) 38 | os.Exit(1) 39 | } 40 | } 41 | 42 | func run(ctx context.Context, listen, remote string, noLog bool, logMask string) error { 43 | mux := http.NewServeMux() 44 | simplep := httputil.NewSingleHostReverseProxy(&url.URL{ 45 | Scheme: "http", 46 | Host: remote, 47 | }) 48 | mux.Handle("/json", simplep) 49 | mux.Handle("/", simplep) 50 | mux.HandleFunc("/devtools/", func(res http.ResponseWriter, req *http.Request) { 51 | id := path.Base(req.URL.Path) 52 | f, logger := createLog(noLog, logMask, id) 53 | if f != nil { 54 | defer f.Close() 55 | } 56 | logger.Printf("---------- connection from %s ----------", req.RemoteAddr) 57 | ver, err := checkVersion(ctx, remote) 58 | if err != nil { 59 | msg := fmt.Sprintf("version error, got: %v", err) 60 | logger.Println(msg) 61 | http.Error(res, msg, http.StatusInternalServerError) 62 | return 63 | } 64 | logger.Printf("endpoint %s reported: %s", remote, string(ver)) 65 | endpoint := "ws://" + remote + path.Join(path.Dir(req.URL.Path), id) 66 | // connect outgoing websocket 67 | logger.Printf("connecting to %s", endpoint) 68 | out, pres, err := wsDialer.Dial(endpoint, nil) 69 | if err != nil { 70 | msg := fmt.Sprintf("could not connect to %s, got: %v", endpoint, err) 71 | logger.Println(msg) 72 | http.Error(res, msg, http.StatusInternalServerError) 73 | return 74 | } 75 | defer pres.Body.Close() 76 | defer out.Close() 77 | logger.Printf("connected to %s", endpoint) 78 | // connect incoming websocket 79 | logger.Printf("upgrading connection on %s", req.RemoteAddr) 80 | in, err := wsUpgrader.Upgrade(res, req, nil) 81 | if err != nil { 82 | msg := fmt.Sprintf("could not upgrade websocket from %s, got: %v", req.RemoteAddr, err) 83 | logger.Println(msg) 84 | http.Error(res, msg, http.StatusInternalServerError) 85 | return 86 | } 87 | defer in.Close() 88 | logger.Printf("upgraded connection on %s", req.RemoteAddr) 89 | ctx, cancel := context.WithCancel(ctx) 90 | defer cancel() 91 | errc := make(chan error, 1) 92 | go proxyWS(ctx, logger, "<-", in, out, errc) 93 | go proxyWS(ctx, logger, "->", out, in, errc) 94 | <-errc 95 | logger.Printf("---------- closing %s ----------", req.RemoteAddr) 96 | }) 97 | return http.ListenAndServe(listen, mux) 98 | } 99 | 100 | const ( 101 | incomingBufferSize = 10 * 1024 * 1024 102 | outgoingBufferSize = 25 * 1024 * 1024 103 | ) 104 | 105 | var wsUpgrader = &websocket.Upgrader{ 106 | ReadBufferSize: incomingBufferSize, 107 | WriteBufferSize: outgoingBufferSize, 108 | CheckOrigin: func(r *http.Request) bool { 109 | return true 110 | }, 111 | } 112 | 113 | var wsDialer = &websocket.Dialer{ 114 | ReadBufferSize: outgoingBufferSize, 115 | WriteBufferSize: incomingBufferSize, 116 | } 117 | 118 | // proxyWS proxies in and out messages for a websocket connection, logging the 119 | // message to the logger with the passed prefix. Any error encountered will be 120 | // sent to errc. 121 | func proxyWS(ctx context.Context, logger *log.Logger, prefix string, in, out *websocket.Conn, errc chan error) { 122 | var mt int 123 | var buf []byte 124 | var err error 125 | for { 126 | select { 127 | default: 128 | mt, buf, err = in.ReadMessage() 129 | if err != nil { 130 | errc <- err 131 | return 132 | } 133 | logger.Println(prefix, string(buf)) 134 | err = out.WriteMessage(mt, buf) 135 | if err != nil { 136 | errc <- err 137 | return 138 | } 139 | case <-ctx.Done(): 140 | return 141 | } 142 | } 143 | } 144 | 145 | // checkVersion retrieves the version information for the remote endpoint, and 146 | // formats it appropriately. 147 | func checkVersion(ctx context.Context, remote string) ([]byte, error) { 148 | req, err := http.NewRequestWithContext(ctx, "GET", "http://"+remote+"/json/version", nil) 149 | if err != nil { 150 | return nil, err 151 | } 152 | cl := &http.Client{} 153 | res, err := cl.Do(req) 154 | if err != nil { 155 | return nil, err 156 | } 157 | defer res.Body.Close() 158 | body, err := ioutil.ReadAll(res.Body) 159 | if err != nil { 160 | return nil, err 161 | } 162 | var v map[string]string 163 | if err := json.Unmarshal(body, &v); err != nil { 164 | return nil, fmt.Errorf("expected json result: %w", err) 165 | } 166 | return body, nil 167 | } 168 | 169 | // createLog creates a log for the specified id based on flags. 170 | func createLog(noLog bool, logMask, id string) (io.Closer, *log.Logger) { 171 | var f io.Closer 172 | var w io.Writer = os.Stdout 173 | if !noLog && logMask != "" { 174 | filename := logMask 175 | if strings.Contains(logMask, "%s") { 176 | filename = fmt.Sprintf(logMask, cleanRE.ReplaceAllString(id, "")) 177 | } 178 | l, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) 179 | if err != nil { 180 | panic(err) 181 | } 182 | f, w = l, io.MultiWriter(os.Stdout, l) 183 | } 184 | return f, log.New(w, "", log.LstdFlags) 185 | } 186 | 187 | var cleanRE = regexp.MustCompile(`[^a-zA-Z0-9_\-\.]`) 188 | --------------------------------------------------------------------------------