├── .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 |
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 | [](https://semaphoreci.com/aschzero/hera)
10 | [](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 |
--------------------------------------------------------------------------------