├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── certificate.go ├── certificate_test.go ├── client.go ├── commander.go ├── go.mod ├── go.sum ├── handler.go ├── handler_test.go ├── listener.go ├── logger.go ├── main.go ├── rootfs └── etc │ ├── cont-init.d │ ├── 01-setup-logs │ └── 02-symlink-certs │ ├── fix-attrs.d │ └── 01-log-permissions │ └── services.d │ └── hera │ └── run ├── service.go ├── service_test.go ├── tunnel.go ├── tunnel_test.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .certs 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Hera 2 | 3 | Contributions are very much welcomed and appreciated. We want to make contributing to this project as easy and transparent as possible, so please read through and become familiar with these guidelines. 4 | 5 | ## Pull Requests 6 | 7 | ### We use [Github Flow](https://guides.github.com/introduction/flow/index.html) for pull requests 8 | When submitting a pull request: 9 | 10 | 1. Fork the repo and create your branch from `master`. 11 | 2. Add tests to your changes whenever possible. 12 | 3. Update the documentation when changing any user-facing behavior. 13 | 4. Submit your PR 14 | 15 | #### Continuous Integration 16 | 17 | Hera uses [Semaphore](https://semaphoreci.com/aschzero/hera) to build pull requests. Ensure the build for your PR succeeds and that the tests pass. 18 | 19 | ## Reporting Bugs 20 | 21 | Report your bug by creating a [new issue](https://github.com/aschzero/hera/issues) in this repository. 22 | 23 | ### Be Descriptive 24 | 25 | **Great bug reports** tend to have: 26 | 27 | - A quick summary and/or background 28 | - Steps to reproduce 29 | - Expected behavior 30 | - Actual behavior 31 | * Include a sample output of your logs if any 32 | - Notes (possibly including why you think this might be happening, or things you've tried that didn't work) 33 | 34 | ## Use a Consistent Coding Style 35 | 36 | Run [golint](https://github.com/golang/lint) against your code until it reports no issues. 37 | 38 | ## License 39 | 40 | By contributing, you agree that your contributions will be licensed under its [MIT License](https://github.com/aschzero/hera/blob/master/LICENSE). 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## Builder image 2 | FROM golang:1.12.1-alpine AS builder 3 | 4 | RUN apk add --no-cache ca-certificates git 5 | 6 | WORKDIR /src 7 | 8 | COPY go.mod . 9 | COPY go.sum . 10 | RUN go mod download 11 | 12 | COPY . . 13 | 14 | RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /dist/hera 15 | 16 | ## Final image 17 | FROM alpine:3.8 18 | 19 | RUN apk add --no-cache ca-certificates curl 20 | 21 | RUN curl -L -s https://github.com/just-containers/s6-overlay/releases/download/v1.21.4.0/s6-overlay-amd64.tar.gz \ 22 | | tar xvzf - -C / 23 | 24 | RUN curl -L -s https://bin.equinox.io/c/VdrWdbjqyF/cloudflared-stable-linux-amd64.tgz \ 25 | | tar xvzf - -C /bin 26 | 27 | RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 28 | 29 | RUN apk del --no-cache curl 30 | 31 | COPY --from=builder /dist/hera /bin/ 32 | 33 | COPY rootfs / 34 | 35 | ENTRYPOINT ["/init"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andrew Schaper 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PWD=$(shell pwd) 2 | 3 | IMAGE=aschzero/hera 4 | BUILDER_IMAGE=$(IMAGE)-builder 5 | 6 | default: image run 7 | 8 | release: build push 9 | 10 | image: 11 | docker build -t $(IMAGE) . 12 | 13 | test: 14 | docker build --target builder -t $(BUILDER_IMAGE) . 15 | docker run --rm -e CGO_ENABLED=0 $(BUILDER_IMAGE) go test 16 | 17 | run: 18 | docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v $(PWD)/.certs:/certs --network=hera $(IMAGE) 19 | 20 | .PHONY:tunnel 21 | tunnel: 22 | docker run --rm --label hera.hostname=$(HOSTNAME) --label hera.port=80 --network=hera nginx 23 | 24 | push: 25 | docker push $(IMAGE):latest 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Hera 2 | 3 | ### Hera automates the creation of [Argo Tunnels](https://www.cloudflare.com/products/argo-tunnel/) to easily and securely expose your local services to the outside world. 4 | 5 | Hera lets you instantly access services outside of your local network with a custom domain using tunnels and is a more secure alternative than using port forwarding or dynamic DNS. 6 | 7 | Hera monitors the state of your configured services to instantly start a tunnel when the container starts. Tunnel processes are also monitored to ensure persistent connections and to restart them in the event of sudden disconnects or shutdowns. Tunnels are automatically restarted when their containers are restarted, or gracefully shutdown if their containers are stopped. 8 | 9 | [![Build Status](https://semaphoreci.com/api/v1/aschzero/hera/branches/master/badge.svg)](https://semaphoreci.com/aschzero/hera) 10 | [![](https://images.microbadger.com/badges/version/aschzero/hera.svg)](https://hub.docker.com/r/aschzero/hera) 11 | 12 | ---- 13 | 14 | * [Features](#features) 15 | * [How Hera Works](#how-hera-works) 16 | * [Getting Started](#getting-started) 17 | * [Prerequisites](#prerequisites) 18 | * [Obtain a Certificate](#obtain-a-certificate) 19 | * [Create a Network](#create-a-network) 20 | * [Running Hera](#running-hera) 21 | * [Required Volumes](#required-volumes) 22 | * [Persisting Logs](#persisting-logs) 23 | * [Tunnel Configuration](#tunnel-configuration) 24 | * [Using Multiple Domains](#using-multiple-domains) 25 | * [Examples](#examples) 26 | * [Subdomains](#subdomains) 27 | * [Docker Compose](#docker-compose) 28 | * [Contributing](#contributing) 29 | 30 | ---- 31 | 32 | # Features 33 | * Continuously monitors the state of your services for automated tunnel creation. 34 | * Revives tunnels on running containers when Hera is restarted. 35 | * Uses the s6 process supervisor to ensure active tunnel processes are kept alive. 36 | * Low memory footprint and high performance – services can be accessed through a tunnel within seconds. 37 | * Requires a minimal amount of configuration so you can get up and running quickly. 38 | * Supports multiple Cloudflare domains. 39 | 40 | # How Hera Works 41 | Hera attaches to the Docker daemon to watch for changes in state of your configured containers. When a new container is started, Hera checks that it has the proper configuration as well as making sure the container can receive connections. If it passes the configuration checks, Hera spawns a new process to create a persistent tunnel connection. 42 | 43 | In the event that a container with an active tunnel has been stopped, Hera gracefully shuts down the tunnel process. 44 | 45 | ℹ️ Hera only monitors the state of containers that have been explicitly configured for Hera. Otherwise, containers and their events are completely ignored. 46 | 47 | # Getting Started 48 | ## Prerequisites 49 | 50 | * Installation of Docker with a client API version of 1.22 or later 51 | * An active domain in Cloudflare with the Argo Tunnel service enabled 52 | * A valid Cloudflare certificate (see [Obtain a Certificate](#obtain-a-certificate)) 53 | 54 | ## Obtain a Certificate 55 | 56 | Hera needs a Cloudflare certificate so it can manage tunnels on your behalf. 57 | 58 | 1. Download a new certificate by visiting https://www.cloudflare.com/a/warp 59 | 2. Rename the certificate to match your domain, ending in `.pem`. For example, a certificate for `mysite.com` should be named `mysite.com.pem`. 60 | 3. Move the certificate to a directory that can be mounted as a volume (see [Required Volumes](#required-volumes)). 61 | 62 | Hera will look for certificates with names matching your tunnels' hostnames and allows the use of multiple certificates. For more info, see [Using Multiple Domains](#using-multiple-domains). 63 | 64 | ## Create a Network 65 | 66 | Hera must be able to connect to your containers and resolve their hostnames before it can create a tunnel. This allows Hera to supply a valid address to Cloudflare during the tunnel creation process. 67 | 68 | It is recommended to create a dedicated network for Hera and attach your desired containers to the new network. 69 | 70 | For example, to create a network named `hera`: 71 | 72 | `docker network create hera` 73 | 74 | --- 75 | 76 | # Running Hera 77 | 78 | Hera can be started with the following command: 79 | 80 | ``` 81 | docker run \ 82 | --name=hera \ 83 | --network=hera \ 84 | -v /var/run/docker.sock:/var/run/docker.sock \ 85 | -v /path/to/certs:/certs \ 86 | aschzero/hera:latest 87 | ``` 88 | 89 | ## Required Volumes 90 | 91 | * `/var/run/docker.sock` – Attaching the Docker daemon as a volume allows Hera to monitor container events. 92 | * `/path/to/certs` – The directory of your Cloudflare certificates. 93 | 94 | ## Persisting Logs 95 | 96 | You can optionally mount a volume to `/var/log/hera` to persist the logs on your host machine: 97 | 98 | ``` 99 | docker run \ 100 | --name=hera \ 101 | --network=hera \ 102 | -v /var/run/docker.sock:/var/run/docker.sock \ 103 | -v /path/to/certs:/certs \ 104 | -v /path/to/logs:/var/log/hera \ 105 | aschzero/hera:latest 106 | ``` 107 | 108 | ℹ️ Tunnel log files are named according to their hostname and can be found at `/var/log/hera/.log` 109 | 110 | ## Tunnel Configuration 111 | 112 | Hera utilizes labels for configuration as a way to let you be explicit about which containers you want enabled. There are only two labels that need to be defined: 113 | 114 | * `hera.hostname` - The hostname is the address you'll use to request the service outside of your home network. It must be the same as the domain you used to configure your certificate and can either be a root domain or subdomain (e.g.: `mysite.com` or `blog.mysite.com`). 115 | 116 | * `hera.port` - The port your service is running on inside the container. 117 | 118 | ⚠️ _Note: you can still expose a different port to your host network if desired, but the `hera.port` label value needs to be the internal port within the container._ 119 | 120 | Here's an example of a container configured for Hera with the `docker run` command: 121 | 122 | ``` 123 | docker run \ 124 | --network=hera \ 125 | --label hera.hostname=mysite.com \ 126 | --label hera.port=80 \ 127 | nginx 128 | ``` 129 | 130 | That's it! After the tunnel propagates, you would be able to see the default nginx welcome page when requesting `mysite.com`. 131 | 132 | Viewing the logs would output something similar to below: 133 | 134 | ``` 135 | $ docker logs -f hera 136 | 137 | [INFO] Hera container found, connecting to 5aa5a300dd0e... 138 | [INFO] Registering tunnel mysite.com 139 | time="2018-08-11T08:38:40Z" level=info msg="Applied configuration from /var/run/s6/services/mysite.com/config.yml" 140 | time="2018-08-11T08:38:40Z" level=info msg="Proxying tunnel requests to http://172.18.0.3:80" 141 | time="2018-08-11T08:38:40Z" level=info msg="Starting metrics server" addr="127.0.0.1:40521" 142 | time="2018-08-11T08:38:41Z" level=info msg="Connected to SEA" 143 | time="2018-08-11T08:38:41Z" level=info msg="Route propagating, it may take up to 1 minute for your new route to become functional" 144 | ... 145 | ``` 146 | 147 | ### Stopping Tunnels 148 | 149 | Stopping a container with an active tunnel will trigger it to shut down: 150 | 151 | ``` 152 | $ docker stop nginx 153 | $ docker logs -f hera 154 | 155 | [INFO] Stopping tunnel mysite.com 156 | time="2018-08-11T09:00:53Z" level=info msg="Initiating graceful shutdown..." 157 | time="2018-08-11T09:00:53Z" level=info msg="Quitting..." 158 | time="2018-08-11T09:00:53Z" level=info msg="Metrics server stopped" 159 | ``` 160 | 161 | ## Using Multiple Domains 162 | 163 | You can use multiple domains as long as there are certificates for each domain with names matching the base hostname of the tunnel. Names are matched according to the pattern `*.domain.tld` and must be placed in the same directory. 164 | 165 | For example, tunnels for `mysite.com` or `blog.mysite.com` will use the certificate named `mysite.com.pem`. 166 | 167 | If a certificate with a matching domain cannot be found, it will look for `cert.pem` in the same directory as a fallback. 168 | 169 | --- 170 | 171 | # Examples 172 | 173 | ## Subdomains 174 | 175 | An example of a tunnel for Kibana pointing to `kibana.mysite.com`: 176 | 177 | ``` 178 | docker run \ 179 | --name=kibana \ 180 | --network=hera \ 181 | --label hera.hostname=kibana.mysite.com \ 182 | --label hera.port=5601 \ 183 | -p 5000:5601 \ 184 | docker.elastic.co/kibana/kibana:6.2.4 185 | ``` 186 | 187 | ## Docker Compose 188 | 189 | ```yaml 190 | version: '3' 191 | 192 | services: 193 | hera: 194 | image: aschzero/hera:latest 195 | volumes: 196 | - /var/run/docker.sock:/var/run/docker.sock 197 | - /path/to/certs:/certs 198 | networks: 199 | - hera 200 | 201 | nginx: 202 | image: nginx:latest 203 | networks: 204 | - hera 205 | labels: 206 | hera.hostname: mysite.com 207 | hera.port: 80 208 | 209 | networks: 210 | hera: 211 | ``` 212 | 213 | # Contributing 214 | 215 | * If you'd like to contribute to the project, refer to the [contributing documentation](https://github.com/aschzero/hera/blob/master/CONTRIBUTING.md). 216 | * Read the [Development](https://github.com/aschzero/hera/wiki/Development) wiki for information on how to setup Hera for local development. 217 | -------------------------------------------------------------------------------- /certificate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | const ( 13 | CertificatePath = "/certs" 14 | ) 15 | 16 | // Certificate holds config a certificate 17 | type Certificate struct { 18 | Name string 19 | Fs afero.Fs 20 | } 21 | 22 | // NewCertificate returns a new Certificate 23 | func NewCertificate(name string, fs afero.Fs) *Certificate { 24 | cert := &Certificate{ 25 | Name: name, 26 | Fs: fs, 27 | } 28 | 29 | return cert 30 | } 31 | 32 | // FindAllCertificates scans the /certs directory for .pem files and returns a collection of Certificates 33 | func FindAllCertificates(fs afero.Fs) ([]*Certificate, error) { 34 | var certs []*Certificate 35 | 36 | files, err := afero.ReadDir(fs, CertificatePath) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | for _, file := range files { 42 | name := file.Name() 43 | 44 | if !strings.HasSuffix(name, ".pem") { 45 | continue 46 | } 47 | 48 | cert := NewCertificate(name, fs) 49 | certs = append(certs, cert) 50 | } 51 | 52 | return certs, nil 53 | } 54 | 55 | // VerifyCertificates returns an error if no valid certificates are found 56 | func VerifyCertificates(fs afero.Fs) error { 57 | certs, err := FindAllCertificates(fs) 58 | 59 | if err != nil || len(certs) == 0 { 60 | return errors.New("No certificates found") 61 | } 62 | 63 | for _, cert := range certs { 64 | log.Infof("Found certificate: %s", cert.Name) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // FindCertificateForHost returns the Certificate associated with the given hostname 71 | func FindCertificateForHost(hostname string, fs afero.Fs) (*Certificate, error) { 72 | certs, err := FindAllCertificates(fs) 73 | if err != nil { 74 | return nil, fmt.Errorf("Unable to scan for available certificates: %s", err) 75 | } 76 | 77 | for _, cert := range certs { 78 | if cert.belongsToHost(hostname) { 79 | return cert, nil 80 | } 81 | } 82 | 83 | return nil, fmt.Errorf("Unable to find certificate for %s", hostname) 84 | } 85 | 86 | // FullPath returns the full path of a certificate file 87 | func (c *Certificate) FullPath() string { 88 | return filepath.Join(CertificatePath, c.Name) 89 | } 90 | 91 | func (c *Certificate) belongsToHost(host string) bool { 92 | baseCertName := strings.Split(c.Name, ".pem")[0] 93 | 94 | return host == baseCertName 95 | } 96 | 97 | func (c *Certificate) isExist() bool { 98 | exists, err := afero.Exists(c.Fs, c.FullPath()) 99 | if err != nil { 100 | log.Errorf("Unable to check certificate: %s", err) 101 | } 102 | 103 | return exists 104 | } 105 | -------------------------------------------------------------------------------- /certificate_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | func TestFindAll(t *testing.T) { 13 | fs := afero.NewMemMapFs() 14 | fs.Mkdir(CertificatePath, os.ModeDir) 15 | 16 | certs := []string{"a.tld.pem", "b.tld.pem", "c.tld"} 17 | 18 | for _, newCert := range certs { 19 | fs.Create(filepath.Join("/certs", newCert)) 20 | } 21 | 22 | foundCerts, err := FindAllCertificates(fs) 23 | if err != nil { 24 | t.Error(err) 25 | } 26 | 27 | if len(foundCerts) != 2 { 28 | t.Errorf("Unexpected cert count, got %d", len(foundCerts)) 29 | } 30 | } 31 | 32 | func TestVerify(t *testing.T) { 33 | fs := afero.NewMemMapFs() 34 | 35 | err := VerifyCertificates(fs) 36 | if err == nil { 37 | t.Error("Expected error") 38 | } 39 | 40 | fs.Create("/certs/a.tld.pem") 41 | 42 | err = VerifyCertificates(fs) 43 | if err != nil { 44 | t.Error(err) 45 | } 46 | } 47 | 48 | func TestFindForHostname(t *testing.T) { 49 | fs := afero.NewMemMapFs() 50 | fs.Create("/certs/schaper.io.pem") 51 | 52 | cert, err := FindCertificateForHost("schaper.io", fs) 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | 57 | if cert.Name != "schaper.io.pem" { 58 | t.Errorf("Unexpected cert for hostname: %s", cert.Name) 59 | } 60 | } 61 | 62 | func TestBelongsToHost(t *testing.T) { 63 | fs := afero.NewMemMapFs() 64 | cert := NewCertificate("hostname.com.pem", fs) 65 | 66 | belongs := cert.belongsToHost("hostname.com") 67 | if !belongs { 68 | t.Errorf("Expected cert and host to belong") 69 | } 70 | 71 | belongs = cert.belongsToHost("horsename.com") 72 | if belongs { 73 | t.Errorf("Expected cert to not belong") 74 | } 75 | } 76 | 77 | func TestFullPath(t *testing.T) { 78 | fs := afero.NewMemMapFs() 79 | name := "mysite.pem" 80 | cert := NewCertificate(name, fs) 81 | 82 | expected := strings.Join([]string{CertificatePath, name}, "/") 83 | if cert.FullPath() != expected { 84 | t.Errorf("Unexpected certificate path, got %s want %s", cert.FullPath(), CertificatePath) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/events" 8 | "github.com/docker/docker/client" 9 | ) 10 | 11 | const ( 12 | Socket = "unix:///var/run/docker.sock" 13 | APIVersion = "v1.22" 14 | ) 15 | 16 | // Client holds an instance of the docker client 17 | type Client struct { 18 | DockerClient *client.Client 19 | } 20 | 21 | // NewClient returns a new Client or an error if not able to connect to the Docker daemon 22 | func NewClient() (*Client, error) { 23 | cli, err := client.NewClient(Socket, APIVersion, nil, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | client := &Client{ 29 | DockerClient: cli, 30 | } 31 | 32 | return client, nil 33 | } 34 | 35 | // Events returns a channel of Docker events 36 | func (c *Client) Events() (<-chan events.Message, <-chan error) { 37 | return c.DockerClient.Events(context.Background(), types.EventsOptions{}) 38 | } 39 | 40 | // ListContainers returns a collection of Docker containers 41 | func (c *Client) ListContainers() ([]types.Container, error) { 42 | return c.DockerClient.ContainerList(context.Background(), types.ContainerListOptions{}) 43 | } 44 | 45 | // Inspect returns the full information for a container with the given container ID 46 | func (c *Client) Inspect(id string) (types.ContainerJSON, error) { 47 | return c.DockerClient.ContainerInspect(context.Background(), id) 48 | } 49 | -------------------------------------------------------------------------------- /commander.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | ) 6 | 7 | // Commander represents an interface for exec commands 8 | type Commander interface { 9 | Run(name string, arg ...string) ([]byte, error) 10 | } 11 | 12 | type Command struct{} 13 | 14 | // Run executes a command returns the output 15 | func (c Command) Run(name string, arg ...string) ([]byte, error) { 16 | out, err := exec.Command(name, arg...).Output() 17 | return out, err 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module hera 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/docker/distribution v2.7.1+incompatible // indirect 7 | github.com/docker/docker v1.13.1 8 | github.com/docker/go-connections v0.4.0 // indirect 9 | github.com/docker/go-units v0.4.0 // indirect 10 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 11 | github.com/opencontainers/go-digest v1.0.0-rc1 // indirect 12 | github.com/pkg/errors v0.8.1 // indirect 13 | github.com/spf13/afero v1.2.2 14 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= 2 | github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 3 | github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= 4 | github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 5 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 6 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 7 | github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= 8 | github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 9 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 10 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 11 | github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= 12 | github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 13 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 14 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 15 | github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= 16 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 17 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 18 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco= 19 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 20 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 21 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 22 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 23 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "golang.org/x/net/publicsuffix" 6 | "net" 7 | "time" 8 | 9 | "github.com/spf13/afero" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/events" 13 | ) 14 | 15 | const ( 16 | heraHostname = "hera.hostname" 17 | heraPort = "hera.port" 18 | ) 19 | 20 | // A Handler is responsible for responding to container start and die events 21 | type Handler struct { 22 | Client *Client 23 | } 24 | 25 | // NewHandler returns a new Handler instance 26 | func NewHandler(client *Client) *Handler { 27 | handler := &Handler{ 28 | Client: client, 29 | } 30 | 31 | return handler 32 | } 33 | 34 | // HandleEvent dispatches an event to the appropriate handler method depending on its status 35 | func (h *Handler) HandleEvent(event events.Message) { 36 | switch status := event.Status; status { 37 | case "start": 38 | err := h.handleStartEvent(event) 39 | if err != nil { 40 | log.Error(err.Error()) 41 | } 42 | 43 | case "die": 44 | err := h.handleDieEvent(event) 45 | if err != nil { 46 | log.Error(err.Error()) 47 | } 48 | } 49 | } 50 | 51 | // HandleContainer allows immediate tunnel creation when hera is started by treating existing 52 | // containers as start events 53 | func (h *Handler) HandleContainer(id string) error { 54 | event := events.Message{ 55 | ID: id, 56 | } 57 | 58 | err := h.handleStartEvent(event) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // handleStartEvent inspects the container from a start event and creates a tunnel if the container 67 | // has been appropriately labeled and a certificate exists for its hostname 68 | func (h *Handler) handleStartEvent(event events.Message) error { 69 | container, err := h.Client.Inspect(event.ID) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | hostname := getLabel(heraHostname, container) 75 | port := getLabel(heraPort, container) 76 | if hostname == "" || port == "" { 77 | return nil 78 | } 79 | 80 | log.Infof("Container found, connecting to %s...", container.ID[:12]) 81 | 82 | ip, err := h.resolveHostname(container) 83 | if err != nil { 84 | return err 85 | } 86 | 87 | cert, err := getCertificate(hostname) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | config := &TunnelConfig{ 93 | IP: ip, 94 | Hostname: hostname, 95 | Port: port, 96 | } 97 | 98 | tunnel := NewTunnel(config, cert) 99 | tunnel.Start() 100 | 101 | return nil 102 | } 103 | 104 | // handleDieEvent inspects the container from a die event and stops the tunnel if one exists. 105 | // An error is returned if a tunnel cannot be found or if the tunnel fails to stop 106 | func (h *Handler) handleDieEvent(event events.Message) error { 107 | container, err := h.Client.Inspect(event.ID) 108 | if err != nil { 109 | return err 110 | } 111 | 112 | hostname := getLabel("hera.hostname", container) 113 | if hostname == "" { 114 | return nil 115 | } 116 | 117 | tunnel, err := GetTunnelForHost(hostname) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | err = tunnel.Stop() 123 | if err != nil { 124 | return err 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // resolveHostname returns the IP address of a container from its hostname. 131 | // An error is returned if the hostname cannot be resolved after five attempts. 132 | func (h *Handler) resolveHostname(container types.ContainerJSON) (string, error) { 133 | var resolved []string 134 | var err error 135 | 136 | attempts := 0 137 | maxAttempts := 5 138 | 139 | for attempts < maxAttempts { 140 | attempts++ 141 | resolved, err = net.LookupHost(container.Config.Hostname) 142 | 143 | if err != nil { 144 | time.Sleep(2 * time.Second) 145 | log.Infof("Unable to connect, retrying... (%d/%d)", attempts, maxAttempts) 146 | 147 | continue 148 | } 149 | 150 | return resolved[0], nil 151 | } 152 | 153 | return "", fmt.Errorf("Unable to connect to %s", container.ID[:12]) 154 | } 155 | 156 | // getLabel returns the label value from a given label name and container JSON. 157 | func getLabel(name string, container types.ContainerJSON) string { 158 | value, ok := container.Config.Labels[name] 159 | if !ok { 160 | return "" 161 | } 162 | 163 | return value 164 | } 165 | 166 | // getCertificate returns a Certificate for a given hostname. 167 | // An error is returned if the root hostname cannot be parsed or if the certificate cannot be found. 168 | func getCertificate(hostname string) (*Certificate, error) { 169 | rootHostname, err := getRootDomain(hostname) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | cert, err := FindCertificateForHost(rootHostname, afero.NewOsFs()) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | return cert, nil 180 | } 181 | 182 | // getRootDomain returns the root domain for a given hostname 183 | func getRootDomain(hostname string) (string, error) { 184 | domain, err := publicsuffix.EffectiveTLDPlusOne(hostname) 185 | if err != nil { 186 | return "", err 187 | } 188 | 189 | return domain, nil 190 | } 191 | -------------------------------------------------------------------------------- /handler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetRootDomain(t *testing.T) { 8 | domains := map[string]string{ 9 | "sub.domain.com": "domain.com", 10 | "domain.net.za": "domain.net.za", 11 | "sub.domain.org.au": "domain.org.au", 12 | } 13 | 14 | for domain, expected := range domains { 15 | actual, err := getRootDomain(domain) 16 | 17 | if err != nil { 18 | t.Errorf("Got error: %v", err) 19 | } 20 | 21 | if actual != expected { 22 | t.Errorf("Unexpected domain, got %s", actual) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /listener.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | // Listener holds config for an event listener and is used to listen for container events 10 | type Listener struct { 11 | Client *Client 12 | Fs afero.Fs 13 | } 14 | 15 | // NewListener returns a new Listener 16 | func NewListener() (*Listener, error) { 17 | client, err := NewClient() 18 | if err != nil { 19 | log.Errorf("Unable to connect to Docker: %s", err) 20 | return nil, err 21 | } 22 | 23 | listener := &Listener{ 24 | Client: client, 25 | Fs: afero.NewOsFs(), 26 | } 27 | 28 | return listener, nil 29 | } 30 | 31 | // Revive revives tunnels for currently running containers 32 | func (l *Listener) Revive() error { 33 | handler := NewHandler(l.Client) 34 | containers, err := l.Client.ListContainers() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | for _, c := range containers { 40 | err := handler.HandleContainer(c.ID) 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // Listen listens for container events to be handled 50 | func (l *Listener) Listen() { 51 | log.Info("Hera is listening") 52 | 53 | handler := NewHandler(l.Client) 54 | messages, errs := l.Client.Events() 55 | 56 | for { 57 | select { 58 | case event := <-messages: 59 | handler.HandleEvent(event) 60 | 61 | case err := <-errs: 62 | if err != nil && err != io.EOF { 63 | log.Error(err.Error()) 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | logging "github.com/op/go-logging" 8 | ) 9 | 10 | const ( 11 | LogDir = "/var/log/hera" 12 | ) 13 | 14 | func InitLogger(name string) { 15 | log := logging.MustGetLogger(name) 16 | logPath := filepath.Join(LogDir, name) 17 | 18 | stderrBackend := logging.NewLogBackend(os.Stderr, "", 0) 19 | strderrBackendFormat := logging.MustStringFormatter(`[%{level}] %{message}`) 20 | strderrBackendFormatter := logging.NewBackendFormatter(stderrBackend, strderrBackendFormat) 21 | 22 | logFile, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) 23 | if err != nil { 24 | log.Errorf("Unable to open file for logging: %s", err) 25 | } 26 | 27 | logFileBackend := logging.NewLogBackend(logFile, "", 0) 28 | logFileBackendFormat := logging.MustStringFormatter(`%{time:15:04:00.000} [%{level}] %{message}`) 29 | logFileBackendFormatter := logging.NewBackendFormatter(logFileBackend, logFileBackendFormat) 30 | 31 | logging.SetBackend(strderrBackendFormatter, logFileBackendFormatter) 32 | } 33 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/op/go-logging" 5 | ) 6 | 7 | var log = logging.MustGetLogger("hera") 8 | 9 | func main() { 10 | InitLogger("hera") 11 | 12 | listener, err := NewListener() 13 | if err != nil { 14 | log.Errorf("Unable to start: %s", err) 15 | } 16 | 17 | log.Infof("Hera v%s has started", CurrentVersion) 18 | 19 | err = VerifyCertificates(listener.Fs) 20 | if err != nil { 21 | log.Error(err.Error()) 22 | } 23 | 24 | err = listener.Revive() 25 | if err != nil { 26 | log.Error(err.Error()) 27 | } 28 | 29 | listener.Listen() 30 | } 31 | -------------------------------------------------------------------------------- /rootfs/etc/cont-init.d/01-setup-logs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mkdir -p /var/log/hera 4 | -------------------------------------------------------------------------------- /rootfs/etc/cont-init.d/02-symlink-certs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | OLD_DIR=/root/.cloudflared 4 | 5 | if [ -d "$OLD_DIR" ]; then 6 | ln -s $OLD_DIR /certs 7 | echo "[WARNING] Deprecation notice: certificates will need to be mounted to /certs instead of /root/.cloudflared. The old path will not be supported in the near future." 8 | fi 9 | -------------------------------------------------------------------------------- /rootfs/etc/fix-attrs.d/01-log-permissions: -------------------------------------------------------------------------------- 1 | /var/log true nobody,32768:32768 0755 0755 -------------------------------------------------------------------------------- /rootfs/etc/services.d/hera/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/execlineb 2 | 3 | foreground { 4 | hera 5 | } 6 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | const ( 12 | ServicesPath = "/var/run/s6/services" 13 | LogPath = "/var/log/hera" 14 | ) 15 | 16 | var fs = afero.NewOsFs() 17 | 18 | // Service holds config for an s6 service 19 | type Service struct { 20 | Hostname string 21 | Commander 22 | } 23 | 24 | // NewService returns a new Service. Services are used to start and stop tunnel processes, 25 | // as well as supervise processes to ensure they are kept alive. 26 | func NewService(hostname string) *Service { 27 | service := &Service{ 28 | Hostname: hostname, 29 | Commander: Command{}, 30 | } 31 | 32 | return service 33 | } 34 | 35 | // servicePath returns the full path for the service 36 | func (s *Service) servicePath() string { 37 | return filepath.Join(ServicesPath, s.Hostname) 38 | } 39 | 40 | // ConfigFilePath returns the full path for the service config file 41 | func (s *Service) ConfigFilePath() string { 42 | return filepath.Join(s.servicePath(), "config.yml") 43 | } 44 | 45 | // RunFilePath returns the full path for the service run command 46 | func (s *Service) RunFilePath() string { 47 | return filepath.Join(s.servicePath(), "run") 48 | } 49 | 50 | // supervisePath returns the full path for the service supervise command 51 | func (s *Service) supervisePath() string { 52 | return filepath.Join(s.servicePath(), "supervise") 53 | } 54 | 55 | // LogFilePath returns the full path for the service log file 56 | func (s *Service) LogFilePath() string { 57 | logPath := []string{filepath.Join(LogPath, s.Hostname), "log"} 58 | 59 | return strings.Join(logPath, ".") 60 | } 61 | 62 | // Create creates a new service directory if one does not already exist 63 | func (s *Service) Create() error { 64 | exists, err := afero.DirExists(fs, s.servicePath()) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if !exists { 70 | fs.Mkdir(s.servicePath(), os.ModePerm) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | // Supervise supervises a service 77 | func (s *Service) Supervise() error { 78 | _, err := s.Commander.Run("s6-svscanctl", "-a", ServicesPath) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | // Start starts a service 87 | func (s *Service) Start() error { 88 | _, err := s.Commander.Run("s6-svc", "-u", s.servicePath()) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | return nil 94 | } 95 | 96 | // Stop stops a service 97 | func (s *Service) Stop() error { 98 | _, err := s.Commander.Run("s6-svc", "-d", s.servicePath()) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // Restart restarts a service 107 | func (s *Service) Restart() error { 108 | err := s.waitUntilDown() 109 | if err != nil { 110 | return err 111 | } 112 | 113 | err = s.Start() 114 | if err != nil { 115 | return err 116 | } 117 | 118 | return nil 119 | } 120 | 121 | // waitUntilDown returns when the service is down 122 | func (s *Service) waitUntilDown() error { 123 | _, err := s.Commander.Run("s6-svwait", "-d", s.servicePath()) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | // IsSupervised returns a bool to indicate if a service is supervised or not 132 | func (s *Service) IsSupervised() (bool, error) { 133 | registered, err := afero.DirExists(fs, s.supervisePath()) 134 | if err != nil { 135 | return false, err 136 | } 137 | 138 | return registered, nil 139 | } 140 | 141 | // IsRunning returns a bool to indicate if a service is running or not 142 | func (s *Service) IsRunning() (bool, error) { 143 | out, err := s.Commander.Run("s6-svstat", "-u", s.servicePath()) 144 | if err != nil { 145 | return false, err 146 | } 147 | 148 | return strings.Contains(string(out), "true"), nil 149 | } 150 | -------------------------------------------------------------------------------- /service_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/spf13/afero" 8 | ) 9 | 10 | var service = NewService("site.tld") 11 | 12 | type MockCommander struct { 13 | mockRun func() ([]byte, error) 14 | } 15 | 16 | func (c MockCommander) Run(name string, arg ...string) ([]byte, error) { 17 | return c.mockRun() 18 | } 19 | 20 | func TestServicePath(t *testing.T) { 21 | expected := "/var/run/s6/services/site.tld" 22 | actual := service.servicePath() 23 | 24 | if actual != expected { 25 | t.Errorf("Unexpected service path, want %s got %s", actual, expected) 26 | } 27 | } 28 | 29 | func TestConfigFilePath(t *testing.T) { 30 | expected := "/var/run/s6/services/site.tld/config.yml" 31 | actual := service.ConfigFilePath() 32 | 33 | if actual != expected { 34 | t.Errorf("Unexpected service path, want %s got %s", actual, expected) 35 | } 36 | } 37 | 38 | func TestRunFilePath(t *testing.T) { 39 | expected := "/var/run/s6/services/site.tld/run" 40 | actual := service.RunFilePath() 41 | 42 | if actual != expected { 43 | t.Errorf("Unexpected run file path, want %s got %s", actual, expected) 44 | } 45 | } 46 | 47 | func TestLogFilePath(t *testing.T) { 48 | expected := "/var/log/hera/site.tld.log" 49 | actual := service.LogFilePath() 50 | 51 | if actual != expected { 52 | t.Errorf("Unexpected log file path, want %s got %s", actual, expected) 53 | } 54 | } 55 | 56 | func TestCreate(t *testing.T) { 57 | fs = afero.NewMemMapFs() 58 | err := service.Create() 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | 63 | exists, err := afero.DirExists(fs, service.servicePath()) 64 | if err != nil { 65 | t.Error(err) 66 | } 67 | 68 | if !exists { 69 | t.Error("Expected service dir") 70 | } 71 | } 72 | 73 | func TestIsSupervised(t *testing.T) { 74 | fs = afero.NewMemMapFs() 75 | supervised, err := service.IsSupervised() 76 | if err != nil { 77 | t.Error(err) 78 | } 79 | 80 | if supervised { 81 | t.Errorf("Expected service to be unsupervised") 82 | } 83 | 84 | path := service.supervisePath() 85 | fs.Mkdir(path, os.ModePerm) 86 | 87 | supervised, err = service.IsSupervised() 88 | if err != nil { 89 | t.Error(err) 90 | } 91 | 92 | if !supervised { 93 | t.Errorf("Expected service to be supervised") 94 | } 95 | } 96 | 97 | func TestIsRunning(t *testing.T) { 98 | service.Commander = &MockCommander{ 99 | mockRun: func() ([]byte, error) { 100 | return []byte("true"), nil 101 | }, 102 | } 103 | 104 | running, err := service.IsRunning() 105 | if err != nil { 106 | t.Error(err) 107 | } 108 | 109 | if !running { 110 | t.Error("Service should be running") 111 | } 112 | 113 | service.Commander = &MockCommander{ 114 | mockRun: func() ([]byte, error) { 115 | return []byte(""), nil 116 | }, 117 | } 118 | 119 | running, err = service.IsRunning() 120 | if err != nil { 121 | t.Error(err) 122 | } 123 | 124 | if running { 125 | t.Error("Service should not be running") 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tunnel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | var ( 12 | registry = make(map[string]*Tunnel) 13 | ) 14 | 15 | // Tunnel holds the corresponding config, certificate, and service for a tunnel 16 | type Tunnel struct { 17 | Config *TunnelConfig 18 | Certificate *Certificate 19 | Service *Service 20 | } 21 | 22 | // TunnelConfig holds the necessary configuration for a tunnel 23 | type TunnelConfig struct { 24 | IP string 25 | Hostname string 26 | Port string 27 | } 28 | 29 | // NewTunnel returns a Tunnel with its corresponding config and certificate 30 | func NewTunnel(config *TunnelConfig, certificate *Certificate) *Tunnel { 31 | service := NewService(config.Hostname) 32 | 33 | tunnel := &Tunnel{ 34 | Config: config, 35 | Certificate: certificate, 36 | Service: service, 37 | } 38 | 39 | return tunnel 40 | } 41 | 42 | // GetTunnelForHost returns the tunnel for a given hostname. 43 | // An error is returned if a tunnel is not found. 44 | func GetTunnelForHost(hostname string) (*Tunnel, error) { 45 | tunnel, ok := registry[hostname] 46 | 47 | if !ok { 48 | return nil, fmt.Errorf("No tunnel exists for %s", hostname) 49 | } 50 | 51 | return tunnel, nil 52 | } 53 | 54 | // Start starts a tunnel 55 | func (t *Tunnel) Start() error { 56 | err := t.prepareService() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | err = t.startService() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | registry[t.Config.Hostname] = t 67 | 68 | return nil 69 | } 70 | 71 | // Stop stops a tunnel 72 | func (t *Tunnel) Stop() error { 73 | log.Infof("Stopping tunnel %s", t.Config.Hostname) 74 | 75 | err := t.Service.Stop() 76 | if err != nil { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // prepareService creates the service and necessary files for the tunnel service 84 | func (t *Tunnel) prepareService() error { 85 | err := t.Service.Create() 86 | if err != nil { 87 | return err 88 | } 89 | 90 | err = t.writeConfigFile() 91 | if err != nil { 92 | return err 93 | } 94 | 95 | err = t.writeRunFile() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // startService starts the tunnel service 104 | func (t *Tunnel) startService() error { 105 | supervised, err := t.Service.IsSupervised() 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if !supervised { 111 | log.Infof("Registering tunnel %s", t.Config.Hostname) 112 | 113 | err := t.Service.Supervise() 114 | if err != nil { 115 | return err 116 | } 117 | return nil 118 | } 119 | 120 | running, err := t.Service.IsRunning() 121 | if err != nil { 122 | return err 123 | } 124 | 125 | if running { 126 | log.Infof("Restarting tunnel %s", t.Config.Hostname) 127 | 128 | err := t.Service.Restart() 129 | if err != nil { 130 | return err 131 | } 132 | } else { 133 | log.Infof("Starting tunnel %s", t.Config.Hostname) 134 | 135 | err := t.Service.Start() 136 | if err != nil { 137 | return err 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | 144 | // writeConfigFile creates the config file for a tunnel 145 | func (t *Tunnel) writeConfigFile() error { 146 | configLines := []string{ 147 | "hostname: %s", 148 | "url: %s:%s", 149 | "logfile: %s", 150 | "origincert: %s", 151 | "no-autoupdate: true", 152 | } 153 | 154 | contents := fmt.Sprintf(strings.Join(configLines[:], "\n"), t.Config.Hostname, t.Config.IP, t.Config.Port, t.Service.LogFilePath(), t.Certificate.FullPath()) 155 | 156 | err := afero.WriteFile(fs, t.Service.ConfigFilePath(), []byte(contents), 0644) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // writeRunFile creates the run file for a tunnel 165 | func (t *Tunnel) writeRunFile() error { 166 | runLines := []string{ 167 | "#!/bin/sh", 168 | "exec cloudflared --config %s", 169 | } 170 | contents := fmt.Sprintf(strings.Join(runLines[:], "\n"), t.Service.ConfigFilePath()) 171 | 172 | err := afero.WriteFile(fs, t.Service.RunFilePath(), []byte(contents), os.ModePerm) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | return nil 178 | } 179 | -------------------------------------------------------------------------------- /tunnel_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/afero" 7 | ) 8 | 9 | func newTunnel() *Tunnel { 10 | config := &TunnelConfig{ 11 | IP: "172.23.0.4", 12 | Hostname: "site.tld", 13 | Port: "80", 14 | } 15 | cert := NewCertificate("site.tld.pem", afero.NewMemMapFs()) 16 | 17 | return NewTunnel(config, cert) 18 | } 19 | 20 | func TestWriteConfigFile(t *testing.T) { 21 | fs = afero.NewMemMapFs() 22 | tunnel := newTunnel() 23 | 24 | err := tunnel.writeConfigFile() 25 | if err != nil { 26 | t.Error(err) 27 | } 28 | 29 | exists, err := afero.Exists(fs, tunnel.Service.ConfigFilePath()) 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | 34 | if !exists { 35 | t.Error("Expected config to exist") 36 | } 37 | } 38 | 39 | func TestWriteRunFile(t *testing.T) { 40 | fs = afero.NewMemMapFs() 41 | tunnel := newTunnel() 42 | 43 | err := tunnel.writeRunFile() 44 | if err != nil { 45 | t.Error(err) 46 | } 47 | 48 | exists, err := afero.Exists(fs, tunnel.Service.RunFilePath()) 49 | if err != nil { 50 | t.Error(err) 51 | } 52 | 53 | if !exists { 54 | t.Error("Expected run to exist") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const ( 4 | CurrentVersion = "0.2.5" 5 | ) 6 | --------------------------------------------------------------------------------