├── LICENCE ├── README.md ├── handler.go ├── handler_test.go ├── hooks ├── bitbucket.go ├── github.go ├── hook.go └── jenkins.go ├── http.go └── main.go /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Christopher Orr 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 | git-webhook-proxy 2 | ================= 3 | 4 | Acts as a proxy for incoming webhooks between your Git hosting provider and your continuous integration server. 5 | 6 | When a Git commit webhook is received, the repository in question will be mirrored locally (or updated, if it already exists), and then the webhook will be passed on to your CI server, where it can start a build, using the up-to-date local mirror. 7 | 8 | Problem 9 | ------- 10 | If you run a CI server which has multiple jobs using the same Git repository, you may find a lot of time is wasted with cloning the repository. Especially if new jobs are created often (e.g. on-the-fly), or workspaces get cleaned out often. 11 | 12 | The Git plugin for Jenkins allows the usage of a "reference repo" — essentially using the `git clone --reference` behaviour — which lets multiple jobs access Git objects via a single repository on local disk, saving on storage and network access. 13 | 14 | However, this first requires that local reference repository is cloned, and subsequently kept up-to-date. 15 | Ensuring the repository is always up-to-date and ready for your CI server to build from, even when webhooks arrive seconds after commits are pushed, can be difficult. 16 | 17 | Solution 18 | -------- 19 | This server generates such local repositories on demand — using `git clone --mirror` — transparent to the CI server, whenever a webhook is received. When further hooks are received, the local mirror is updated from the remote (using `git remote update`). 20 | 21 | This process blocks until the repository has been cloned or updated, and then the webhook is forwarded to the CI server, whose response will be returned to the original initiator of the webhook. 22 | 23 | Download 24 | -------- 25 | Binaries for select platforms are available from the [releases page](https://github.com/orrc/git-webhook-proxy/releases). 26 | 27 | Building 28 | -------- 29 | Once you have the [Go development tools](http://golang.org/doc/install) installed, you should be able to run: 30 | `go get github.com/orrc/git-webhook-proxy` 31 | 32 | Configuration 33 | ------------- 34 | ### git-webhook-proxy 35 | You should run git-webhook-proxy on the same machine as your CI server, or on a machine that has access to the same disk space as your CI server. 36 | 37 | Running `git-webhook-proxy --help` will display the command line options. 38 | 39 | You must explicitly specify the address(es) to listen on, e.g. `--listen 127.0.0.1:8000` for HTTP, or `--tls-listen :8443` for TLS. 40 | To accept TLS connections, you must provide your TLS certificate in PEM format (if intermediate certificates are required, append them to your certificate file) and private key. 41 | 42 | The interface and port given should be reachable from the public internet, if you want to receive webhooks from services like GitHub. 43 | 44 | The directory to which Git repositories will be mirrored is set by the `--mirror-path` flag. 45 | 46 | The URL to which incoming webhook requests should be forwarded, is configured with `--remote`. This parameter can be omitted if you just want to keep a repository mirror up-to-date, and don't want to forward incoming webhooks to another server. 47 | 48 | ### Webhooks 49 | Set up webhooks as normal at your Git hosting provider. 50 | 51 | e.g. For GitHub, use the Jenkins webhook type. 52 | 53 | ### Jenkins 54 | For each job that should share a local repository, the job should be configured as normal, i.e. using the remote Git URL to clone from. 55 | 56 | In addition, you should choose "Additional Behaviours > Add > Advanced clone behaviours" which will reveal some more options. 57 | Set the "Path of the reference repo to use during clone" to the value of `--mirror-path` plus the repository directory name. 58 | 59 | The repository directory name is determined from the Git clone URL, and has the form `/.git`. 60 | e.g. The Git URL `git@github.com:example/code` has the name `github.com/example/code.git`. 61 | 62 | So, with the `--mirror-path` of `/opt/git/mirrors`, the full path to enter into Jenkins would be `/opt/git/mirrors/github.com/example/code.git`. 63 | 64 | Limitations 65 | ----------- 66 | Currently, only requests with the exact path of `/git/notifyCommit?url=` or `/github-webhook/` are processed. 67 | 68 | These are the standard URL formats used by the Git and GitHub plugins for Jenkins respectively, making this tool a drop-in replacement if you use Jenkins. 69 | 70 | In the future, this will be more flexible. 71 | 72 | Licence 73 | ------- 74 | The MIT License (MIT) 75 | 76 | Copyright (c) 2014 Christopher Orr 77 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/orrc/git-webhook-proxy/hooks" 7 | "log" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "os" 12 | "os/exec" 13 | "reflect" 14 | "strings" 15 | "sync" 16 | ) 17 | 18 | type Handler struct { 19 | gitPath string 20 | mirrorRootDir string 21 | remoteUrl string 22 | proxy http.Handler 23 | requests map[string]*sync.Mutex 24 | } 25 | 26 | func NewHandler(gitPath, mirrorRootDir, remoteUrl string) (h *Handler, err error) { 27 | backendUrl, err := url.Parse(remoteUrl) 28 | proxy := httputil.NewSingleHostReverseProxy(backendUrl) 29 | 30 | // Ensure we send the correct Host header to the backend 31 | defaultDirector := proxy.Director 32 | proxy.Director = func(req *http.Request) { 33 | defaultDirector(req) 34 | req.Host = backendUrl.Host 35 | } 36 | 37 | h = &Handler{ 38 | gitPath: gitPath, 39 | mirrorRootDir: mirrorRootDir, 40 | remoteUrl: remoteUrl, 41 | proxy: proxy, 42 | requests: make(map[string]*sync.Mutex), 43 | } 44 | return 45 | } 46 | 47 | func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 48 | // Log request 49 | log.Printf("Incoming webhook from %s %s %s", req.RemoteAddr, req.Method, req.URL) 50 | 51 | // Determine which handler to use 52 | // TODO: This won't work well for e.g. "/jenkins/git/notifyCommit" 53 | var hookType hooks.Webhook 54 | switch req.URL.Path { 55 | case "/git/notifyCommit": 56 | hookType = hooks.JenkinsHook{} 57 | case "/github-webhook/": 58 | hookType = hooks.GitHubFormHook{} 59 | default: 60 | log.Println("No hook handler found!") 61 | http.NotFound(w, req) 62 | return 63 | } 64 | 65 | // Parse the Git repo URI from the webhook request 66 | repoUri, err := hookType.GetGitRepoUri(req) 67 | if err != nil { 68 | msg := fmt.Sprintf("%s returned error: %s", reflect.TypeOf(hookType), err) 69 | log.Println(msg) 70 | http.Error(w, msg, http.StatusInternalServerError) 71 | return 72 | } 73 | if repoUri == "" { 74 | msg := fmt.Sprintf("%s could not determine the repository URL from this request", reflect.TypeOf(hookType)) 75 | log.Println(msg) 76 | http.Error(w, msg, http.StatusInternalServerError) 77 | return 78 | } 79 | 80 | // Check whether we're already working on updating this repo 81 | // TODO: Coalesce multiple blocked requests 82 | if _, exists := h.requests[repoUri]; !exists { 83 | h.requests[repoUri] = &sync.Mutex{} 84 | } 85 | lock := h.requests[repoUri] 86 | lock.Lock() 87 | defer lock.Unlock() 88 | 89 | // Clone or mirror the repo 90 | // TODO: Test what happens if the HTTP client disappears in the middle of a long clone 91 | err = h.updateOrCloneRepoMirror(repoUri) 92 | if err != nil { 93 | log.Println(err.Error()) 94 | http.Error(w, err.Error(), http.StatusInternalServerError) 95 | return 96 | } 97 | 98 | if h.remoteUrl != "" { 99 | // Proxy the original webhook request to the backend 100 | log.Printf("Proxying webhook request to %s/%s\n", h.remoteUrl, req.URL) 101 | h.proxy.ServeHTTP(w, req) 102 | } 103 | } 104 | 105 | func (h *Handler) updateOrCloneRepoMirror(repoUri string) error { 106 | // Check whether we have cloned this repo already 107 | repoPath := h.getMirrorPathForRepo(repoUri) 108 | if _, err := os.Stat(repoPath); os.IsNotExist(err) { 109 | // TODO: Also need to somehow detect whether a directory has a full clone, or failed... 110 | err = h.cloneRepo(repoUri) 111 | if err != nil { 112 | err = errors.New(fmt.Sprintf("Failed to clone %s: %s", repoUri, err.Error())) 113 | } 114 | return err 115 | } 116 | 117 | // If we already have clone the repo, ensure that it is up-to-date 118 | log.Printf("Updating mirror at %s", repoPath) 119 | cmd := exec.Command(h.gitPath, "remote", "update", "-p") 120 | cmd.Dir = repoPath 121 | err := cmd.Run() 122 | if err == nil { 123 | log.Printf("Successfully updated %s", repoPath) 124 | 125 | // Also run "git gc", if required, to clean up afterwards 126 | cmd := exec.Command(h.gitPath, "gc", "--prune=now", "--aggressive", "--auto") 127 | cmd.Dir = repoPath 128 | 129 | // But we don't really care about the outcome 130 | cmd.Run() 131 | } else { 132 | err = fmt.Errorf("Failed to update %s: %s", repoPath, err.Error()) 133 | } 134 | return err 135 | } 136 | 137 | func (h *Handler) cloneRepo(repoUri string) error { 138 | // Ensure the mirror root directory exists 139 | err := os.MkdirAll(h.mirrorRootDir, 0700) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | // Delete the directory if cloning fails 145 | defer func() { 146 | if err != nil { 147 | os.Remove(h.getMirrorPathForRepo(repoUri)) 148 | } 149 | }() 150 | 151 | // TODO: We may need to transform incoming repo URIs to add user credentials so they can be cloned 152 | log.Printf("Cloning %s to %s", repoUri, h.mirrorRootDir) 153 | cmd := exec.Command(h.gitPath, "clone", "--mirror", repoUri, getDirNameForRepo(repoUri)) 154 | cmd.Dir = h.mirrorRootDir 155 | err = cmd.Run() 156 | if err == nil { 157 | log.Printf("Successfully cloned %s", repoUri) 158 | } 159 | return err 160 | } 161 | 162 | func (h *Handler) getMirrorPathForRepo(repoUri string) string { 163 | return fmt.Sprintf("%s/%s", h.mirrorRootDir, getDirNameForRepo(repoUri)) 164 | } 165 | 166 | func getDirNameForRepo(repoUri string) string { 167 | repoUri = strings.TrimSpace(repoUri) 168 | repoUri = strings.TrimSuffix(repoUri, "/") 169 | repoUri = strings.TrimSuffix(repoUri, ".git") 170 | repoUri = strings.ToLower(repoUri) 171 | 172 | if strings.Contains(repoUri, "://") { 173 | uri, _ := url.Parse(repoUri) 174 | if i := strings.Index(uri.Host, ":"); i != -1 { 175 | uri.Host = uri.Host[:i] 176 | } 177 | return fmt.Sprintf("%s/%s.git", uri.Host, uri.Path[1:]) 178 | } 179 | 180 | if i := strings.Index(repoUri, "@"); i != -1 { 181 | repoUri = repoUri[i+1:] 182 | } 183 | repoUri = strings.Replace(repoUri, ":", "/", 1) 184 | repoUri = strings.Replace(repoUri, "//", "/", -1) 185 | return repoUri + ".git" 186 | } 187 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | . "github.com/smartystreets/goconvey/convey" 6 | "testing" 7 | ) 8 | 9 | type repoTestData struct { 10 | input string 11 | expect string 12 | } 13 | 14 | func TestGetDirNameForRepo(t *testing.T) { 15 | data := []repoTestData{ 16 | {"ssh://host.xz/path/to/repo.git/", "host.xz/path/to/repo.git"}, 17 | {"ssh://host.xz:22/path/to/repo.git/", "host.xz/path/to/repo.git"}, 18 | {"ssh://user@host.xz:22/path/to/repo.git/", "host.xz/path/to/repo.git"}, 19 | 20 | {"git://host.xz/path/to/repo.git/", "host.xz/path/to/repo.git"}, 21 | {"git://host.xz:22/path/to/repo.git/", "host.xz/path/to/repo.git"}, 22 | {"git://user@host.xz:9418/path/to/repo.git/", "host.xz/path/to/repo.git"}, 23 | 24 | {"http://git.example.com/user/My-Repo", "git.example.com/user/my-repo.git"}, 25 | {"https://git.example.com:8443/user/My-Repo", "git.example.com/user/my-repo.git"}, 26 | {"https://scm@git.example.com:8443/user/My-Repo.git", "git.example.com/user/my-repo.git"}, 27 | 28 | {"example.com:/a/b/c/", "example.com/a/b/c.git"}, 29 | {"git@github.com:example/testing", "github.com/example/testing.git"}, 30 | {"git@git.assembla.com:foo-bar-app.git", "git.assembla.com/foo-bar-app.git"}, 31 | } 32 | 33 | for _, d := range data { 34 | Convey(fmt.Sprintf("Dir for '%s' should be '%s'", d.input, d.expect), t, func() { 35 | result := getDirNameForRepo(d.input) 36 | So(result, ShouldEqual, d.expect) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /hooks/bitbucket.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | // bitbucketHookPayload reflects the parts of the Bitbucket 11 | // webhook JSON structure that we are interested in 12 | type bitbucketHookPayload struct { 13 | BaseUrl string `json:"canon_url"` 14 | Repository struct { 15 | RepoPath string `json:"absolute_url"` 16 | } 17 | } 18 | 19 | // A BitbucketHook contains push info in JSON within an x-www-form-urlencoded POST body 20 | type BitbucketHook struct{} 21 | 22 | func (h BitbucketHook) GetGitRepoUri(req *http.Request) (string, error) { 23 | form, err := getRequestForm(req) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | formValue := form.Get("payload") 29 | 30 | var payload bitbucketHookPayload 31 | json.Unmarshal([]byte(formValue), &payload) 32 | repoHttpUrl := strings.TrimSuffix(payload.BaseUrl+payload.Repository.RepoPath, "/") 33 | if repoHttpUrl == "" { 34 | return "", errors.New("No URL found in webhook payload") 35 | } 36 | 37 | return getSshUriForUrl(repoHttpUrl) 38 | } 39 | -------------------------------------------------------------------------------- /hooks/github.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | // gitHubHookPayload reflects the parts of the GitHub 10 | // webhook JSON structure that we are interested in 11 | type gitHubHookPayload struct { 12 | Repository struct { 13 | Url string 14 | } 15 | } 16 | 17 | // A GitHubFormHook contains push info in JSON within an x-www-form-urlencoded POST body 18 | type GitHubFormHook struct{} 19 | 20 | func (h GitHubFormHook) GetGitRepoUri(req *http.Request) (string, error) { 21 | form, err := getRequestForm(req) 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | formValue := form.Get("payload") 27 | return getSshUriFromGitHubWebhookJson(formValue) 28 | } 29 | 30 | // A GitHubFormHook contains push info in JSON 31 | type GitHubJsonHook struct{} 32 | 33 | func (h GitHubJsonHook) GetGitRepoUri(req *http.Request) (string, error) { 34 | body, err := getRequestBody(req) 35 | if err != nil { 36 | return "", err 37 | } 38 | return getSshUriFromGitHubWebhookJson(body) 39 | } 40 | 41 | func getSshUriFromGitHubWebhookJson(body string) (string, error) { 42 | var payload gitHubHookPayload 43 | json.Unmarshal([]byte(body), &payload) 44 | repoHttpUrl := payload.Repository.Url 45 | if repoHttpUrl == "" { 46 | return "", errors.New("No URL found in webhook payload") 47 | } 48 | 49 | return getSshUriForUrl(repoHttpUrl) 50 | } 51 | -------------------------------------------------------------------------------- /hooks/hook.go: -------------------------------------------------------------------------------- 1 | // Package hooks contains various Webhook implementations 2 | package hooks 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | // A Webhook knows how to parse a particular webhook format call 13 | // in order to determine the Git repository URI it refers to. 14 | type Webhook interface { 15 | // GetGitRepoUri determines the Git repository URI a webhook refers to 16 | GetGitRepoUri(*http.Request) (string, error) 17 | } 18 | 19 | // bodyHolder is a type implementing io.ReadCloser, 20 | // used to make a copy of http.Request.Body 21 | type bodyHolder struct { 22 | *bytes.Buffer 23 | } 24 | 25 | // Required to implement the io.Closer part 26 | func (r bodyHolder) Close() error { return nil } 27 | 28 | // getRequest form returns the form parameters from an HTTP request, 29 | // including POST parameters, without modifying the request Body 30 | func getRequestForm(req *http.Request) (url.Values, error) { 31 | // Take a copy of the request body 32 | bodyBuf, err := ioutil.ReadAll(req.Body) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // Replace the request body, so we can parse the form from it 38 | req.Body = bodyHolder{bytes.NewBuffer(bodyBuf)} 39 | if err := req.ParseForm(); err != nil { 40 | return nil, err 41 | } 42 | 43 | // Replace the request body once again for the next consumer 44 | req.Body = bodyHolder{bytes.NewBuffer(bodyBuf)} 45 | 46 | // Return the parsed form 47 | return req.Form, nil 48 | } 49 | 50 | // getRequestBody returns the HTTP request body as a string, 51 | // without modifying the original request Body 52 | func getRequestBody(req *http.Request) (string, error) { 53 | // Take a copy of the request body 54 | bodyBuf, err := ioutil.ReadAll(req.Body) 55 | if err != nil { 56 | return "", err 57 | } 58 | 59 | // Replace the request body for the next consumer 60 | req.Body = bodyHolder{bytes.NewBuffer(bodyBuf)} 61 | 62 | // Return the body to the caller 63 | return string(bodyBuf), nil 64 | } 65 | 66 | func getSshUriForUrl(httpUrl string) (string, error) { 67 | u, err := url.Parse(httpUrl) 68 | return fmt.Sprintf("git@%s:%s.git", u.Host, u.Path[1:]), err 69 | } 70 | -------------------------------------------------------------------------------- /hooks/jenkins.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | ) 7 | 8 | // A JenkinsHook contains the repository URI in the "url" GET parameter 9 | type JenkinsHook struct{} 10 | 11 | func (h JenkinsHook) GetGitRepoUri(req *http.Request) (string, error) { 12 | params, err := url.ParseQuery(req.URL.RawQuery) 13 | if err != nil { 14 | return "", err 15 | } 16 | return params.Get("url"), nil 17 | } 18 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func serveHttp(address string, handler http.Handler) { 9 | log.Println("Listening for HTTP requests at", address) 10 | err := http.ListenAndServe(address, handler) 11 | if err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | 16 | func serveTls(address, certFile, keyFile string, handler http.Handler) { 17 | log.Println("Listening for TLS requests at ", address) 18 | err := http.ListenAndServeTLS(address, certFile, keyFile, handler) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | ) 11 | 12 | var ( 13 | // Listening web server options 14 | listenAddress = flag.String("listen", "", "Specify an address to accept HTTP requests, e.g. \":8000\"") 15 | tlsListenAddress = flag.String("tls-listen", "", "Specify an address to accept HTTPS requests, e.g. \":8443\"") 16 | tlsCertificateFile = flag.String("tls-cert", "proxy.crt", "Path to the TLS certificate chain to use") 17 | tlsPrivateKeyFile = flag.String("tls-key", "proxy.key", "Path to the private key for the TLS certificate") 18 | 19 | // Remote web server options 20 | remoteUrl = flag.String("remote", "", "HTTP URL to forward incoming hooks to, upon successful mirroring") 21 | 22 | // Git options 23 | mirrorPath = flag.String("mirror-path", "/tmp/mirror", "Directory to which git repositories should be mirrored") 24 | gitPath = flag.String("git", "/usr/bin/git", "Path to the git binary") 25 | ) 26 | 27 | func usage() { 28 | fmt.Fprintln(os.Stderr, "Receives git webhooks, keeps a local mirror of the repo up-to-date, then forwards the webhook to another server.\n") 29 | fmt.Fprintln(os.Stderr, "Usage:", os.Args[0]) 30 | flag.PrintDefaults() 31 | os.Exit(2) 32 | } 33 | 34 | func startListening(handler http.Handler, address, tlsAddress, tlsCertFile, tlsKeyFile string) { 35 | isRunning := false 36 | if *listenAddress != "" { 37 | go serveHttp(address, handler) 38 | isRunning = true 39 | } 40 | if *tlsListenAddress != "" { 41 | go serveTls(tlsAddress, tlsCertFile, tlsKeyFile, handler) 42 | isRunning = true 43 | } 44 | if !isRunning { 45 | log.Fatal("Quitting as neither HTTP nor TLS were enabled") 46 | } 47 | } 48 | 49 | func main() { 50 | // Get the command line options 51 | flag.Usage = usage 52 | flag.Parse() 53 | 54 | // Show some basic config info 55 | log.Println("Git repositories will be mirrored to: ", *mirrorPath) 56 | if *remoteUrl != "" { 57 | log.Println("Webhook requests will be forwarded to:", *remoteUrl) 58 | } 59 | 60 | // Start the listening web server 61 | handler, err := NewHandler(*gitPath, *mirrorPath, *remoteUrl) 62 | if err != nil { 63 | log.Fatal("Invalid config:", err) 64 | } 65 | startListening(handler, *listenAddress, *tlsListenAddress, *tlsCertificateFile, *tlsPrivateKeyFile) 66 | 67 | // Wait for our eventual death 68 | c := make(chan os.Signal, 1) 69 | signal.Notify(c, os.Interrupt, os.Kill) 70 | <-c 71 | log.Println("Shutting down...") 72 | } 73 | --------------------------------------------------------------------------------