├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── beautify.go ├── cmd ├── forward │ └── main.go └── mallory │ └── main.go ├── config.go ├── direct.go ├── go.mod ├── go.sum ├── http.go ├── logger.go ├── mallory.json ├── mallory.service ├── server.go ├── singleflight.go └── ssh.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | cmd/mallory/mallory 6 | 7 | # Folders 8 | _obj 9 | _test 10 | 11 | # Architecture specific extensions/prefixes 12 | *.[568vq] 13 | [568vq].out 14 | 15 | *.cgo1.go 16 | *.cgo2.c 17 | _cgo_defun.c 18 | _cgo_gotypes.go 19 | _cgo_export.* 20 | 21 | _testmain.go 22 | 23 | *.exe 24 | *.test 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - tip 5 | 6 | install: 7 | - go get github.com/justmao945/mallory/cmd/mallory 8 | 9 | # TODO: add test mallory 10 | script: 11 | 12 | notifications: 13 | email: 14 | recipients: 15 | - justmao945@gmail.com 16 | on_success: change 17 | on_failure: always 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.11.3-stretch 2 | COPY . /go/src/mallory 3 | WORKDIR /go/src/mallory/cmd/mallory 4 | RUN go get . 5 | RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' . 6 | RUN CGO_ENABLED=0 GOOS=linux go install -a -ldflags '-extldflags "-static"' 7 | ENTRYPOINT ["/go/bin/mallory"] 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 JianjunMao 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## mallory 2 | HTTP/HTTPS proxy over SSH. 3 | 4 | ## Installation 5 | * Local machine: `go install github.com/justmao945/mallory/cmd/mallory@latest` 6 | * Remote server: need our old friend sshd 7 | 8 | ## Configuration 9 | ### Config file 10 | Default path is `$HOME/.config/mallory.json`, can be set when start program 11 | ``` 12 | mallory -config path/to/config.json 13 | ``` 14 | 15 | Content: 16 | * `id_rsa` is the path to our private key file, can be generated by `ssh-keygen` 17 | * `local_smart` is the local address to serve HTTP proxy with smart detection of destination host 18 | * `local_normal` is similar to `local_smart` but send all traffic through remote SSH server without destination host detection 19 | * `remote` is the remote address of SSH server 20 | * `blocked` is a list of domains that need use proxy, any other domains will connect to their server directly 21 | 22 | ```json 23 | { 24 | "id_rsa": "$HOME/.ssh/id_rsa", 25 | "local_smart": ":1315", 26 | "local_normal": ":1316", 27 | "remote": "ssh://user@vm.me:22", 28 | "blocked": [ 29 | "angularjs.org", 30 | "golang.org", 31 | "google.com", 32 | "google.co.jp", 33 | "googleapis.com", 34 | "googleusercontent.com", 35 | "google-analytics.com", 36 | "gstatic.com", 37 | "twitter.com", 38 | "youtube.com" 39 | ] 40 | } 41 | ``` 42 | 43 | Blocked list in config file will be reloaded automatically when updated, and you can do it manually: 44 | ``` 45 | # send signal to reload 46 | kill -USR2 47 | 48 | # or use reload command by sending http request 49 | mallory -reload 50 | ``` 51 | 52 | ### System config 53 | * Set both HTTP and HTTPS proxy to `localhost` with port `1315` to use with block list 54 | * Set env var `http_proxy` and `https_proxy` to `localhost:1316` for terminal usage 55 | 56 | ### Get the right suffix name for a domain 57 | ``` 58 | mallory -suffix www.google.com 59 | ``` 60 | 61 | ### A simple command to forward all traffic for the given port 62 | ```sh 63 | # install it: go get github.com/justmao945/mallory/cmd/forward 64 | 65 | # all traffic through port 20022 will be forwarded to destination.com:22 66 | forward -network tcp -listen :20022 -forward destination.com:22 67 | 68 | # you can ssh to destination:22 through localhost:20022 69 | ssh root@localhost -p 20022 70 | ``` 71 | 72 | ### TODO 73 | * return http error when unable to dial 74 | * add host to list automatically when unable to dial 75 | * support multiple remote servers 76 | 77 | ### Docker container 78 | 79 | Considering the following config file: 80 | 81 | ``` 82 | $ cat mallory.json 83 | { 84 | "id_rsa": "/tmp/id_rsa", 85 | "local_smart": ":1315", 86 | "local_normal": ":1316", 87 | "remote": "ssh://bhenrion@10.151.0.11:22" 88 | } 89 | ``` 90 | 91 | You can run the container (`zoobab/mallory`) my mounting the config file, the SSH key, and mapping the 2 ports: 92 | 93 | ``` 94 | $ docker run -v $PWD/mallory.json:/root/.config/mallory.json -p 1316:1316 -p 1315:1315 -v $PWD/.ssh/id_rsa:/tmp/id_rsa zoobab/mallory 95 | mallory: 2020/03/30 16:51:10 main.go:22: Starting... 96 | mallory: 2020/03/30 16:51:10 main.go:23: PID: 1 97 | mallory: 2020/03/30 16:51:10 config.go:103: Loading: /root/.config/mallory.json 98 | mallory: 2020/03/30 16:51:10 main.go:30: Connecting remote SSH server: ssh://bhenrion@10.151.0.11:22 99 | mallory: 2020/03/30 16:51:10 main.go:38: Local normal HTTP proxy: :1316 100 | mallory: 2020/03/30 16:51:10 main.go:48: Local smart HTTP proxy: :1315 101 | ``` 102 | 103 | My use case was to connect to a Kubernetes cluster (Openshift) installed behind an SSH bastion: 104 | 105 | ``` 106 | $ export http_proxy=http://localhost:1316 107 | $ export https_proxy=https://localhost:1316 108 | $ oc login https://master.mycluster.zoobab.com:8443 109 | Authentication required for https://master.mycluster.zoobab.com:8443 (openshift) 110 | Username: bhenrion 111 | Password: 112 | Login successful. 113 | ``` 114 | -------------------------------------------------------------------------------- /beautify.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | ) 7 | 8 | // Duration to e.g. 432ms or 12s, human readable translation 9 | func BeautifyDuration(d time.Duration) string { 10 | u, ms, s := uint64(d), uint64(time.Millisecond), uint64(time.Second) 11 | if d < 0 { 12 | u = -u 13 | } 14 | switch { 15 | case u < ms: 16 | return "0" 17 | case u < s: 18 | return strconv.FormatUint(u/ms, 10) + "ms" 19 | default: 20 | return strconv.FormatUint(u/s, 10) + "s" 21 | } 22 | } 23 | 24 | func BeautifySize(s int64) string { 25 | switch { 26 | case s < 1024: 27 | return strconv.FormatInt(s, 10) + "B" 28 | case s < 1024*1024: 29 | return strconv.FormatInt(s/1024, 10) + "KB" 30 | default: 31 | return strconv.FormatInt(s/1024/1024, 10) + "MB" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cmd/forward/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "io" 6 | "log" 7 | "net" 8 | "os" 9 | "time" 10 | ) 11 | 12 | var ( 13 | L = log.New(os.Stdout, "forward: ", log.Lshortfile|log.LstdFlags) 14 | 15 | FNetwork = flag.String("network", "tcp", "network protocol") 16 | FListen = flag.String("listen", ":20022", "listen on this port") 17 | FForward = flag.String("forward", ":80", "destination address and port") 18 | ) 19 | 20 | func isChanClose(ch chan int) bool { 21 | select { 22 | case _, received := <-ch: 23 | return !received 24 | default: 25 | } 26 | return false 27 | } 28 | func main() { 29 | flag.Parse() 30 | 31 | L.Printf("Listening on %s for %s...\n", *FListen, *FNetwork) 32 | ln, err := net.Listen(*FNetwork, *FListen) 33 | if err != nil { 34 | L.Fatal(err) 35 | } 36 | 37 | for id := 0; ; id++ { 38 | conn, err := ln.Accept() 39 | if err != nil { 40 | L.Printf("%d: %s\n", id, err) 41 | continue 42 | } 43 | L.Printf("%d: new %s\n", id, conn.RemoteAddr()) 44 | 45 | if tcpConn := conn.(*net.TCPConn); tcpConn != nil { 46 | L.Printf("%d: setup keepalive for TCP connection\n", id) 47 | tcpConn.SetKeepAlive(true) 48 | tcpConn.SetKeepAlivePeriod(30 * time.Second) 49 | } 50 | 51 | go func(myid int, conn net.Conn) { 52 | defer conn.Close() 53 | c, err := net.Dial(*FNetwork, *FForward) 54 | if err != nil { 55 | L.Printf("%d: %s\n", myid, err) 56 | return 57 | } 58 | L.Printf("%d: new %s <-> %s\n", myid, c.RemoteAddr(), conn.RemoteAddr()) 59 | defer c.Close() 60 | wait := make(chan int) 61 | go func() { 62 | n, err := io.Copy(c, conn) 63 | if err != nil { 64 | L.Printf("%d: %s\n", myid, err) 65 | } 66 | L.Printf("%d: %s -> %s %d bytes\n", myid, conn.RemoteAddr(), c.RemoteAddr(), n) 67 | if !isChanClose(wait) { 68 | close(wait) 69 | } 70 | }() 71 | go func() { 72 | n, err := io.Copy(conn, c) 73 | if err != nil { 74 | L.Printf("%d: %s\n", myid, err) 75 | } 76 | L.Printf("%d: %s -> %s %d bytes\n", myid, c.RemoteAddr(), conn.RemoteAddr(), n) 77 | if !isChanClose(wait) { 78 | close(wait) 79 | } 80 | 81 | }() 82 | <-wait 83 | L.Printf("%d: connection closed\n", myid) 84 | }(id, conn) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cmd/mallory/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "golang.org/x/net/publicsuffix" 11 | 12 | . "github.com/justmao945/mallory" 13 | ) 14 | 15 | var ( 16 | FConfig = flag.String("config", "$HOME/.config/mallory.json", "config file") 17 | FSuffix = flag.String("suffix", "", "print pulbic suffix for the given domain") 18 | FReload = flag.Bool("reload", false, "send signal to reload config file") 19 | ) 20 | 21 | func serve() { 22 | L.Printf("Starting...\n") 23 | L.Printf("PID: %d\n", os.Getpid()) 24 | 25 | c, err := NewConfig(*FConfig) 26 | if err != nil { 27 | L.Fatalln(err) 28 | } 29 | 30 | L.Printf("Connecting remote SSH server: %s\n", c.File.RemoteServer) 31 | 32 | wait := make(chan int) 33 | go func() { 34 | normal, err := NewServer(NormalSrv, c) 35 | if err != nil { 36 | L.Fatalln(err) 37 | } 38 | L.Printf("Local normal HTTP proxy: %s\n", c.File.LocalNormalServer) 39 | L.Fatalln(http.ListenAndServe(c.File.LocalNormalServer, normal)) 40 | wait <- 1 41 | }() 42 | 43 | go func() { 44 | smart, err := NewServer(SmartSrv, c) 45 | if err != nil { 46 | L.Fatalln(err) 47 | } 48 | L.Printf("Local smart HTTP proxy: %s\n", c.File.LocalSmartServer) 49 | L.Fatalln(http.ListenAndServe(c.File.LocalSmartServer, smart)) 50 | wait <- 1 51 | }() 52 | <-wait 53 | } 54 | 55 | func printSuffix() { 56 | host := *FSuffix 57 | tld, _ := publicsuffix.EffectiveTLDPlusOne(host) 58 | fmt.Printf("EffectiveTLDPlusOne: %s\n", tld) 59 | suffix, _ := publicsuffix.PublicSuffix(host) 60 | fmt.Printf("PublicSuffix: %s\n", suffix) 61 | } 62 | 63 | func reload() { 64 | file, err := NewConfigFile(os.ExpandEnv(*FConfig)) 65 | if err != nil { 66 | L.Fatal(err) 67 | } 68 | res, err := http.Get(fmt.Sprintf("http://%s/reload", file.LocalNormalServer)) 69 | if err != nil { 70 | L.Fatal(err) 71 | } 72 | defer res.Body.Close() 73 | body, err := ioutil.ReadAll(res.Body) 74 | if err != nil { 75 | L.Fatal(err) 76 | } 77 | fmt.Printf("%s\n", body) 78 | } 79 | 80 | func main() { 81 | flag.Parse() 82 | 83 | if *FSuffix != "" { 84 | printSuffix() 85 | } else if *FReload { 86 | reload() 87 | } else { 88 | serve() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | "sort" 10 | "sync" 11 | "syscall" 12 | 13 | "gopkg.in/fsnotify.v1" 14 | ) 15 | 16 | // Memory representation for mallory.json 17 | type ConfigFile struct { 18 | // private file file 19 | PrivateKey string `json:"id_rsa"` 20 | // local addr to listen and serve, default is 127.0.0.1:1315 21 | LocalSmartServer string `json:"local_smart"` 22 | // local addr to listen and serve, default is 127.0.0.1:1316 23 | LocalNormalServer string `json:"local_normal"` 24 | // remote addr to connect, e.g. ssh://user@linode.my:22 25 | RemoteServer string `json:"remote"` 26 | // direct to proxy dial timeout 27 | ShouldProxyTimeoutMS int `json:"should_proxy_timeout_ms"` 28 | // blocked host list 29 | BlockedList []string `json:"blocked"` 30 | } 31 | 32 | // Load file from path 33 | func NewConfigFile(path string) (self *ConfigFile, err error) { 34 | self = &ConfigFile{} 35 | buf, err := ioutil.ReadFile(path) 36 | if err != nil { 37 | return 38 | } 39 | err = json.Unmarshal(buf, self) 40 | if err != nil { 41 | return 42 | } 43 | self.PrivateKey = os.ExpandEnv(self.PrivateKey) 44 | sort.Strings(self.BlockedList) 45 | return 46 | } 47 | 48 | // test whether host is in blocked list or not 49 | func (self *ConfigFile) Blocked(host string) bool { 50 | i := sort.SearchStrings(self.BlockedList, host) 51 | return i < len(self.BlockedList) && self.BlockedList[i] == host 52 | } 53 | 54 | // Provide global config for mallory 55 | type Config struct { 56 | // file path 57 | Path string 58 | // config file content 59 | File *ConfigFile 60 | // File wather 61 | Watcher *fsnotify.Watcher 62 | // mutex for config file 63 | mutex sync.RWMutex 64 | loaded bool 65 | } 66 | 67 | func NewConfig(path string) (self *Config, err error) { 68 | // watch config file changes 69 | watcher, err := fsnotify.NewWatcher() 70 | if err != nil { 71 | return 72 | } 73 | 74 | self = &Config{ 75 | Path: os.ExpandEnv(path), 76 | Watcher: watcher, 77 | } 78 | err = self.Load() 79 | return 80 | } 81 | 82 | func (self *Config) Reload() (err error) { 83 | file, err := NewConfigFile(self.Path) 84 | if err != nil { 85 | L.Printf("Reload %s failed: %s\n", self.Path, err) 86 | } else { 87 | L.Printf("Reload %s\n", self.Path) 88 | self.mutex.Lock() 89 | self.File = file 90 | self.mutex.Unlock() 91 | } 92 | return 93 | } 94 | 95 | // reload config file 96 | func (self *Config) Load() (err error) { 97 | if self.loaded { 98 | panic("can not be reload manually") 99 | } 100 | self.loaded = true 101 | 102 | // first time to load 103 | L.Printf("Loading: %s\n", self.Path) 104 | self.File, err = NewConfigFile(self.Path) 105 | if err != nil { 106 | return 107 | } 108 | 109 | // Watching the whole directory instead of the individual path. 110 | // Because many editors won't write to file directly, they copy 111 | // the original one and rename it. 112 | err = self.Watcher.Add(filepath.Dir(self.Path)) 113 | if err != nil { 114 | return 115 | } 116 | 117 | go func() { 118 | for { 119 | select { 120 | case event := <-self.Watcher.Events: 121 | if event.Op&fsnotify.Write == fsnotify.Write && event.Name == self.Path { 122 | self.Reload() 123 | } 124 | case err := <-self.Watcher.Errors: 125 | L.Printf("Watching failed: %s\n", err) 126 | } 127 | } 128 | }() 129 | 130 | sc := make(chan os.Signal, 1) 131 | signal.Notify(sc, syscall.SIGHUP) 132 | go func() { 133 | for s := range sc { 134 | if s == syscall.SIGHUP { 135 | self.Reload() 136 | } 137 | } 138 | }() 139 | 140 | return 141 | } 142 | 143 | // test whether host is in blocked list or not 144 | func (self *Config) Blocked(host string) bool { 145 | self.mutex.RLock() 146 | blocked := self.File.Blocked(host) 147 | self.mutex.RUnlock() 148 | return blocked 149 | } 150 | -------------------------------------------------------------------------------- /direct.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | var ( 12 | ErrShouldProxy = errors.New("should proxy") 13 | ) 14 | 15 | type closeWriter interface { 16 | CloseWrite() error 17 | } 18 | 19 | // Direct fetcher 20 | type Direct struct { 21 | Tr *http.Transport 22 | } 23 | 24 | // Create and initialize 25 | func NewDirect(shouldProxyTimeout time.Duration) *Direct { 26 | if shouldProxyTimeout == 0 { 27 | shouldProxyTimeout = 200 * time.Millisecond 28 | } 29 | tr := http.DefaultTransport.(*http.Transport) 30 | tr.Dial = (&net.Dialer{ 31 | Timeout: shouldProxyTimeout, 32 | }).Dial 33 | return &Direct{Tr: tr} 34 | } 35 | 36 | // Data flow: 37 | // 1. Receive request R1 from client 38 | // 2. Re-post request R1 to remote server(the one client want to connect) 39 | // 3. Receive response P1 from remote server 40 | // 4. Send response P1 to client 41 | func (self *Direct) ServeHTTP(w http.ResponseWriter, r *http.Request) (err error) { 42 | if r.Method == "CONNECT" { 43 | L.Println("this function can not handle CONNECT method") 44 | http.Error(w, r.Method, http.StatusMethodNotAllowed) 45 | return 46 | } 47 | start := time.Now() 48 | 49 | // Client.Do is different from DefaultTransport.RoundTrip ... 50 | // Client.Do will change some part of request as a new request of the server. 51 | // The underlying RoundTrip never changes anything of the request. 52 | resp, err := self.Tr.RoundTrip(r) 53 | if err != nil { 54 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 55 | L.Printf("RoundTrip: %s, reproxy...\n", err.Error()) 56 | err = ErrShouldProxy 57 | return 58 | } 59 | L.Printf("RoundTrip: %s\n", err.Error()) 60 | http.Error(w, err.Error(), http.StatusInternalServerError) 61 | return 62 | } 63 | defer resp.Body.Close() 64 | 65 | // please prepare header first and write them 66 | CopyHeader(w, resp) 67 | w.WriteHeader(resp.StatusCode) 68 | 69 | n, err := io.Copy(w, resp.Body) 70 | if err != nil { 71 | L.Printf("Copy: %s\n", err.Error()) 72 | http.Error(w, err.Error(), http.StatusInternalServerError) 73 | return 74 | } 75 | 76 | d := BeautifyDuration(time.Since(start)) 77 | ndtos := BeautifySize(n) 78 | L.Printf("RESPONSE %s %s in %s <-%s\n", r.URL.Host, resp.Status, d, ndtos) 79 | return 80 | } 81 | 82 | // Data flow: 83 | // 1. Receive CONNECT request from the client 84 | // 2. Dial the remote server(the one client want to conenct) 85 | // 3. Send 200 OK to client if the connection is established 86 | // 4. Exchange data between client and server 87 | func (self *Direct) Connect(w http.ResponseWriter, r *http.Request) (err error) { 88 | if r.Method != "CONNECT" { 89 | L.Println("this function can only handle CONNECT method") 90 | http.Error(w, r.Method, http.StatusMethodNotAllowed) 91 | return 92 | } 93 | start := time.Now() 94 | 95 | // Use Hijacker to get the underlying connection 96 | hij, ok := w.(http.Hijacker) 97 | if !ok { 98 | s := "Server does not support Hijacker" 99 | L.Println(s) 100 | http.Error(w, s, http.StatusInternalServerError) 101 | return 102 | } 103 | 104 | // connect the remote client directly 105 | dst, err := self.Tr.Dial("tcp", r.URL.Host) 106 | if err != nil { 107 | if nerr, ok := err.(net.Error); ok && nerr.Timeout() { 108 | L.Printf("RoundTrip: %s, reproxy...\n", err.Error()) 109 | err = ErrShouldProxy 110 | return 111 | } 112 | L.Printf("Dial: %s\n", err.Error()) 113 | http.Error(w, err.Error(), http.StatusInternalServerError) 114 | return 115 | } 116 | defer dst.Close() 117 | 118 | src, _, err := hij.Hijack() 119 | if err != nil { 120 | L.Printf("Hijack: %s\n", err.Error()) 121 | http.Error(w, err.Error(), http.StatusInternalServerError) 122 | return 123 | } 124 | defer src.Close() 125 | 126 | // Once connected successfully, return OK 127 | src.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")) 128 | 129 | // Proxy is no need to know anything, just exchange data between the client 130 | // the the remote server. 131 | copyAndWait := func(dst, src net.Conn, c chan int64) { 132 | n, err := io.Copy(dst, src) 133 | if err != nil { 134 | L.Printf("Copy: %s\n", err.Error()) 135 | // FIXME: how to report error to dst ? 136 | } 137 | if tcpConn, ok := dst.(closeWriter); ok { 138 | tcpConn.CloseWrite() 139 | } 140 | c <- n 141 | } 142 | 143 | // client to remote 144 | stod := make(chan int64) 145 | go copyAndWait(dst, src, stod) 146 | 147 | // remote to client 148 | dtos := make(chan int64) 149 | go copyAndWait(src, dst, dtos) 150 | 151 | var nstod, ndtos int64 152 | for i := 0; i < 2; { 153 | select { 154 | case nstod = <-stod: 155 | i++ 156 | case ndtos = <-dtos: 157 | i++ 158 | } 159 | } 160 | d := BeautifyDuration(time.Since(start)) 161 | L.Printf("CLOSE %s after %s ->%s <-%s\n", 162 | r.URL.Host, d, BeautifySize(nstod), BeautifySize(ndtos)) 163 | return 164 | } 165 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/justmao945/mallory 2 | 3 | go 1.17 4 | 5 | require ( 6 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e 7 | golang.org/x/net v0.0.0-20220524220425-1d687d428aca 8 | gopkg.in/fsnotify.v1 v1.4.7 9 | ) 10 | 11 | require ( 12 | github.com/fsnotify/fsnotify v1.5.4 // indirect 13 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= 2 | github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 3 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= 4 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 5 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 6 | golang.org/x/net v0.0.0-20220524220425-1d687d428aca h1:xTaFYiPROfpPhqrfTIDXj0ri1SpfueYT951s4bAuDO8= 7 | golang.org/x/net v0.0.0-20220524220425-1d687d428aca/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 8 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 9 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 10 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 11 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 12 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 13 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 15 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 16 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 17 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 18 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 19 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 20 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 21 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 22 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | ) 8 | 9 | // HostOnly returns host if has port in addr, or addr if missing port 10 | func HostOnly(addr string) string { 11 | host, _, err := net.SplitHostPort(addr) 12 | if err != nil { 13 | return addr 14 | } else { 15 | return host 16 | } 17 | } 18 | 19 | // copy and overwrite headers from r to w 20 | func CopyHeader(w http.ResponseWriter, r *http.Response) { 21 | // copy headers 22 | dst, src := w.Header(), r.Header 23 | for k, _ := range dst { 24 | dst.Del(k) 25 | } 26 | for k, vs := range src { 27 | for _, v := range vs { 28 | dst.Add(k, v) 29 | } 30 | } 31 | } 32 | 33 | // StatusText returns http status text looks like "200 OK" 34 | func StatusText(c int) string { 35 | return fmt.Sprintf("%d %s", c, http.StatusText(c)) 36 | } 37 | 38 | // Hop-by-hop headers. These are removed when sent to the backend. 39 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html 40 | var hopHeaders = []string{ 41 | // If no Accept-Encoding header exists, Transport will add the headers it can accept 42 | // and would wrap the response body with the relevant reader. 43 | "Accept-Encoding", 44 | "Connection", 45 | "Keep-Alive", 46 | "Proxy-Authenticate", 47 | "Proxy-Authorization", 48 | "Te", // canonicalized version of "TE" 49 | "Trailers", 50 | "Transfer-Encoding", 51 | "Upgrade", 52 | "Proxy-Connection", // added by CURL http://homepage.ntlworld.com/jonathan.deboynepollard/FGA/web-proxy-connection-header.html 53 | } 54 | 55 | func RemoveHopHeaders(h http.Header) { 56 | for _, k := range hopHeaders { 57 | h.Del(k) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // global logger 9 | var L = log.New(os.Stdout, "mallory: ", log.Lshortfile|log.LstdFlags) 10 | -------------------------------------------------------------------------------- /mallory.json: -------------------------------------------------------------------------------- 1 | { 2 | "id_rsa": "$HOME/.ssh/id_rsa", 3 | "local": "127.0.0.1:1315", 4 | "remote": "ssh://jianjun@linode:22", 5 | "blocked": [ 6 | "amazonaws.com", 7 | "angularjs.io", 8 | "angularjs.org", 9 | "awsstatic.com", 10 | "golang.org", 11 | "google.com", 12 | "google.co.jp", 13 | "googleapis.com", 14 | "googleusercontent.com", 15 | "google-analytics.com", 16 | "gstatic.com", 17 | "sourceforge.net", 18 | "twitter.com", 19 | "youtube.com" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /mallory.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=HTTP/HTTPS proxy over SSH 3 | After=network.target 4 | 5 | 6 | [Service] 7 | Type=simple 8 | User=mallory 9 | Group=mallory 10 | ExecStart=/usr/local/bin/mallory -config /etc/mallory/mallory.json 11 | ExecReload=/usr/local/bin/mallory -reload 12 | Restart=always 13 | 14 | [Install] 15 | WantedBy=default.target 16 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Package mallory implements a simple http proxy support direct and GAE remote fetcher 2 | package mallory 3 | 4 | import ( 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "golang.org/x/net/publicsuffix" 10 | ) 11 | 12 | const ( 13 | SmartSrv = iota 14 | NormalSrv 15 | ) 16 | 17 | type AccessType bool 18 | 19 | func (t AccessType) String() string { 20 | if t { 21 | return "PROXY" 22 | } else { 23 | return "DIRECT" 24 | } 25 | } 26 | 27 | type Server struct { 28 | // SmartSrv or NormalSrv 29 | Mode int 30 | // config file 31 | Cfg *Config 32 | // direct fetcher 33 | Direct *Direct 34 | // ssh fetcher, to connect remote proxy server 35 | SSH *SSH 36 | // a cache 37 | BlockedHosts map[string]bool 38 | // for serve http 39 | mutex sync.RWMutex 40 | } 41 | 42 | // Create and intialize 43 | func NewServer(mode int, c *Config) (self *Server, err error) { 44 | ssh, err := NewSSH(c) 45 | if err != nil { 46 | return 47 | } 48 | 49 | shouldProxyTimeout := time.Millisecond * time.Duration(c.File.ShouldProxyTimeoutMS) 50 | 51 | self = &Server{ 52 | Mode: mode, 53 | Cfg: c, 54 | Direct: NewDirect(shouldProxyTimeout), 55 | SSH: ssh, 56 | BlockedHosts: make(map[string]bool), 57 | } 58 | return 59 | } 60 | 61 | func (self *Server) Blocked(host string) bool { 62 | blocked, cached := false, false 63 | host = HostOnly(host) 64 | self.mutex.RLock() 65 | if self.BlockedHosts[host] { 66 | blocked = true 67 | cached = true 68 | } 69 | self.mutex.RUnlock() 70 | 71 | if !blocked { 72 | tld, _ := publicsuffix.EffectiveTLDPlusOne(host) 73 | blocked = self.Cfg.Blocked(tld) 74 | } 75 | 76 | if !blocked { 77 | suffix, _ := publicsuffix.PublicSuffix(host) 78 | blocked = self.Cfg.Blocked(suffix) 79 | } 80 | 81 | if blocked && !cached { 82 | self.mutex.Lock() 83 | self.BlockedHosts[host] = true 84 | self.mutex.Unlock() 85 | } 86 | return blocked 87 | } 88 | 89 | // ServeHTTP proxy accepts requests with following two types: 90 | // - CONNECT 91 | // Generally, this method is used when the client want to connect server with HTTPS. 92 | // In fact, the client can do anything he want in this CONNECT way... 93 | // The request is something like: 94 | // CONNECT www.google.com:443 HTTP/1.1 95 | // Only has the host and port information, and the proxy should not do anything with 96 | // the underlying data. What the proxy can do is just exchange data between client and server. 97 | // After accepting this, the proxy should response 98 | // HTTP/1.1 200 OK 99 | // to the client if the connection to the remote server is established. 100 | // Then client and server start to exchange data... 101 | // 102 | // - non-CONNECT, such as GET, POST, ... 103 | // In this case, the proxy should redo the method to the remote server. 104 | // All of these methods should have the absolute URL that contains the host information. 105 | // A GET request looks like: 106 | // GET weibo.com/justmao945/.... HTTP/1.1 107 | // which is different from the normal http request: 108 | // GET /justmao945/... HTTP/1.1 109 | // Because we can be sure that all of them are http request, we can only redo the request 110 | // to the remote server and copy the reponse to client. 111 | // 112 | func (self *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 113 | use := (self.Blocked(r.URL.Host) || self.Mode == NormalSrv) && r.URL.Host != "" 114 | L.Printf("[%s] %s %s %s\n", AccessType(use), r.Method, r.RequestURI, r.Proto) 115 | 116 | if r.Method == "CONNECT" { 117 | if use { 118 | self.SSH.Connect(w, r) 119 | } else { 120 | err := self.Direct.Connect(w, r) 121 | if err == ErrShouldProxy { 122 | self.SSH.Connect(w, r) 123 | } 124 | } 125 | } else if r.URL.IsAbs() { 126 | // This is an error if is not empty on Client 127 | r.RequestURI = "" 128 | RemoveHopHeaders(r.Header) 129 | if use { 130 | self.SSH.ServeHTTP(w, r) 131 | } else { 132 | err := self.Direct.ServeHTTP(w, r) 133 | if err == ErrShouldProxy { 134 | self.SSH.ServeHTTP(w, r) 135 | } 136 | } 137 | } else if r.URL.Path == "/reload" { 138 | self.reload(w, r) 139 | } else { 140 | L.Printf("%s is not a full URL path\n", r.RequestURI) 141 | } 142 | } 143 | 144 | func (self *Server) reload(w http.ResponseWriter, r *http.Request) { 145 | err := self.Cfg.Reload() 146 | if err != nil { 147 | w.WriteHeader(500) 148 | w.Write([]byte(self.Cfg.Path + ": " + err.Error())) 149 | } else { 150 | w.WriteHeader(200) 151 | w.Write([]byte(self.Cfg.Path + " reloaded")) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /singleflight.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2012 Google Inc. 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 singleflight provides a duplicate function call suppression 18 | // mechanism. 19 | package mallory 20 | 21 | import "sync" 22 | 23 | // call is an in-flight or completed Do call 24 | type call struct { 25 | wg sync.WaitGroup 26 | val interface{} 27 | err error 28 | } 29 | 30 | // Group represents a class of work and forms a namespace in which 31 | // units of work can be executed with duplicate suppression. 32 | type Group struct { 33 | mu sync.Mutex // protects m 34 | m map[string]*call // lazily initialized 35 | } 36 | 37 | // Do executes and returns the results of the given function, making 38 | // sure that only one execution is in-flight for a given key at a 39 | // time. If a duplicate comes in, the duplicate caller waits for the 40 | // original to complete and receives the same results. 41 | func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { 42 | g.mu.Lock() 43 | if g.m == nil { 44 | g.m = make(map[string]*call) 45 | } 46 | if c, ok := g.m[key]; ok { 47 | g.mu.Unlock() 48 | c.wg.Wait() 49 | return c.val, c.err 50 | } 51 | c := new(call) 52 | c.wg.Add(1) 53 | g.m[key] = c 54 | g.mu.Unlock() 55 | 56 | c.val, c.err = fn() 57 | c.wg.Done() 58 | 59 | g.mu.Lock() 60 | delete(g.m, key) 61 | g.mu.Unlock() 62 | 63 | return c.val, c.err 64 | } 65 | -------------------------------------------------------------------------------- /ssh.go: -------------------------------------------------------------------------------- 1 | package mallory 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "os/user" 11 | "sync" 12 | 13 | "golang.org/x/crypto/ssh" 14 | "golang.org/x/crypto/ssh/agent" 15 | ) 16 | 17 | // 18 | type SSH struct { 19 | // global config file 20 | Cfg *Config 21 | // connect URL 22 | URL *url.URL 23 | // SSH client 24 | Client *ssh.Client 25 | // SSH client config 26 | CliCfg *ssh.ClientConfig 27 | // direct fetcher 28 | Direct *Direct 29 | // only re-dial once 30 | sf Group 31 | l sync.RWMutex 32 | } 33 | 34 | // Create and initialize 35 | func NewSSH(c *Config) (self *SSH, err error) { 36 | self = &SSH{ 37 | Cfg: c, 38 | CliCfg: &ssh.ClientConfig{}, 39 | } 40 | // e.g. ssh://user:passwd@192.168.1.1:1122 41 | self.URL, err = url.Parse(c.File.RemoteServer) 42 | if err != nil { 43 | return 44 | } 45 | 46 | if self.URL.User != nil { 47 | self.CliCfg.User = self.URL.User.Username() 48 | } else { 49 | u, err := user.Current() 50 | if err != nil { 51 | return self, err 52 | } 53 | // u.Name is the full name, should not be used 54 | self.CliCfg.User = u.Username 55 | } 56 | 57 | self.CliCfg.HostKeyCallback = ssh.InsecureIgnoreHostKey() 58 | 59 | // 1) try RSA keyring first 60 | for { 61 | id_rsa := c.File.PrivateKey 62 | pem, err := ioutil.ReadFile(id_rsa) 63 | if err != nil { 64 | L.Printf("ReadFile %s failed:%s\n", c.File.PrivateKey, err) 65 | break 66 | } 67 | signer, err := ssh.ParsePrivateKey(pem) 68 | if err != nil { 69 | L.Printf("ParsePrivateKey %s failed:%s\n", c.File.PrivateKey, err) 70 | break 71 | } 72 | self.CliCfg.Auth = append(self.CliCfg.Auth, ssh.PublicKeys(signer)) 73 | // stop !! 74 | break 75 | } 76 | // 2) try password 77 | for { 78 | if self.URL.User == nil { 79 | break 80 | } 81 | if pass, ok := self.URL.User.Password(); ok { 82 | self.CliCfg.Auth = append(self.CliCfg.Auth, ssh.Password(pass)) 83 | } 84 | // stop here!! 85 | break 86 | } 87 | 88 | // 3) try ssh agent 89 | if sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { 90 | agentAuth := ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers) 91 | self.CliCfg.Auth = append(self.CliCfg.Auth, agentAuth) 92 | } 93 | 94 | if len(self.CliCfg.Auth) == 0 { 95 | //TODO: keyboard intercative 96 | err = errors.New("Invalid auth method, please add password or generate ssh keys") 97 | return 98 | } 99 | 100 | // first time to dial to remote server, make sure it is available 101 | self.Client, err = ssh.Dial("tcp", self.URL.Host, self.CliCfg) 102 | if err != nil { 103 | return 104 | } 105 | 106 | dial := func(network, addr string) (c net.Conn, err error) { 107 | self.l.RLock() 108 | cli := self.Client 109 | self.l.RUnlock() 110 | 111 | c, err = cli.Dial(network, addr) 112 | if err == nil { 113 | return 114 | } 115 | 116 | L.Printf("dial %s failed: %s, reconnecting ssh server %s...\n", addr, err, self.URL.Host) 117 | 118 | clif, err := self.sf.Do(network+addr, func() (interface{}, error) { 119 | return ssh.Dial("tcp", self.URL.Host, self.CliCfg) 120 | }) 121 | if err != nil { 122 | L.Printf("connect ssh server %s failed: %s\n", self.URL.Host, err) 123 | return 124 | } 125 | cli = clif.(*ssh.Client) 126 | 127 | self.l.Lock() 128 | self.Client = cli 129 | self.l.Unlock() 130 | 131 | return cli.Dial(network, addr) 132 | } 133 | 134 | self.Direct = &Direct{ 135 | Tr: &http.Transport{Dial: dial}, 136 | } 137 | return 138 | } 139 | 140 | func (self *SSH) ServeHTTP(w http.ResponseWriter, r *http.Request) { 141 | self.Direct.ServeHTTP(w, r) 142 | } 143 | 144 | func (self *SSH) Connect(w http.ResponseWriter, r *http.Request) { 145 | self.Direct.Connect(w, r) 146 | } 147 | --------------------------------------------------------------------------------