diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..f90134b3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+// integration tests are not needed in docker
+// ignoring it let us speed up the integration test
+// development
+integration_test.go
+integration_test/
+
+Dockerfile*
+docker-compose*
+.dockerignore
+.goreleaser.yml
+.git
+.github
+.gitignore
+README.md
+LICENSE
+.vscode
+
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..c0286571
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,39 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+ # The "build" workflow
+ lint:
+ # The type of runner that the job will run on
+ runs-on: ubuntu-latest
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ # Install and run golangci-lint as a separate step, it's much faster this
+ # way because this action has caching. It'll get run again in `make lint`
+ # below, but it's still much faster in the end than installing
+ # golangci-lint manually in the `Run lint` step.
+ - uses: golangci/golangci-lint-action@v2
+ with:
+ args: --timeout 5m
+
+ # Setup Go
+ - name: Setup Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: "1.16.3" # The Go version to download (if necessary) and use.
+
+ # Install all the dependencies
+ - name: Install dependencies
+ run: |
+ go version
+ go install golang.org/x/lint/golint@latest
+ sudo apt update
+ sudo apt install -y make
+
+ - name: Run lint
+ run: make lint
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b191a12c..7f715ca7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,9 +1,11 @@
-name: goreleaser
+---
+name: release
on:
push:
tags:
- - "*" # triggers only if push new tag version
+ - "*" # triggers only if push new tag version
+ workflow_dispatch:
jobs:
goreleaser:
@@ -27,4 +29,48 @@ jobs:
version: latest
args: release --rm-dist
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ docker-release:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@v2
+ with:
+ fetch-depth: 0
+ -
+ name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v3
+ with:
+ # list of Docker images to use as base name for tags
+ images: |
+ ${{ secrets.DOCKERHUB_USERNAME }}/headscale
+ ghcr.io/${{ github.repository_owner }}/headscale
+ tags: |
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=sha
+ -
+ name: Login to DockerHub
+ uses: docker/login-action@v1
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ -
+ name: Login to GHCR
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ -
+ name: Build and push
+ id: docker_build
+ uses: docker/build-push-action@v2
+ with:
+ push: true
+ context: .
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml
new file mode 100644
index 00000000..e939df22
--- /dev/null
+++ b/.github/workflows/test-integration.yml
@@ -0,0 +1,23 @@
+name: CI
+
+on: [pull_request]
+
+jobs:
+ # The "build" workflow
+ integration-test:
+ # The type of runner that the job will run on
+ runs-on: ubuntu-latest
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ # Setup Go
+ - name: Setup Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: "1.16.3"
+
+ - name: Run Integration tests
+ run: go test -tags integration -timeout 30m
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a96971a7..3d254fa6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,36 +10,24 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- - uses: actions/checkout@v2
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
- # Install and run golangci-lint as a separate step, it's much faster this
- # way because this action has caching. It'll get run again in `make lint`
- # below, but it's still much faster in the end than installing
- # golangci-lint manually in the `Run lint` step.
- - uses: golangci/golangci-lint-action@v2
- with:
- args: --timeout 2m
-
- # Setup Go
- - name: Setup Go
- uses: actions/setup-go@v2
- with:
- go-version: '1.16.3' # The Go version to download (if necessary) and use.
+ # Setup Go
+ - name: Setup Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: "1.16.3" # The Go version to download (if necessary) and use.
- # Install all the dependencies
- - name: Install dependencies
- run: |
- go version
- go install golang.org/x/lint/golint@latest
- sudo apt update
- sudo apt install -y make
-
- - name: Run tests
- run: make test
+ # Install all the dependencies
+ - name: Install dependencies
+ run: |
+ go version
+ sudo apt update
+ sudo apt install -y make
- - name: Run lint
- run: make lint
+ - name: Run tests
+ run: make test
- - name: Run build
- run: make
\ No newline at end of file
+ - name: Run build
+ run: make
diff --git a/.gitignore b/.gitignore
index ff4f6664..95d758a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,9 @@
config.json
*.key
/db.sqlite
+*.sqlite3
+
+# Exclude Jetbrains Editors
+.idea
+
+test_output/
diff --git a/Dockerfile b/Dockerfile
index e3fd8efc..9499af22 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,12 +1,19 @@
FROM golang:latest AS build
ENV GOPATH /go
-COPY . /go/src/headscale
+
+COPY go.mod go.sum /go/src/headscale/
WORKDIR /go/src/headscale
+RUN go mod download
+
+COPY . /go/src/headscale
+
RUN go install -a -ldflags="-extldflags=-static" -tags netgo,sqlite_omit_load_extension ./cmd/headscale
RUN test -e /go/bin/headscale
-FROM scratch
-COPY --from=build /go/bin/headscale /go/bin/headscale
+FROM ubuntu:20.04
+
+COPY --from=build /go/bin/headscale /usr/local/bin/headscale
ENV TZ UTC
+
EXPOSE 8080/tcp
-ENTRYPOINT ["/go/bin/headscale"]
+CMD ["headscale"]
diff --git a/Dockerfile.tailscale b/Dockerfile.tailscale
new file mode 100644
index 00000000..c6830f8a
--- /dev/null
+++ b/Dockerfile.tailscale
@@ -0,0 +1,9 @@
+FROM ubuntu:latest
+
+RUN apt-get update \
+ && apt-get install -y gnupg curl \
+ && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.gpg | apt-key add - \
+ && curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
+ && apt-get update \
+ && apt-get install -y tailscale \
+ && rm -rf /var/lib/apt/lists/*
diff --git a/Makefile b/Makefile
index 18498462..482ae868 100644
--- a/Makefile
+++ b/Makefile
@@ -2,13 +2,16 @@
version = $(shell ./scripts/version-at-commit.sh)
build:
- go build -ldflags "-s -w -X main.version=$(version)" cmd/headscale/headscale.go
+ go build -ldflags "-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.version=$(version)" cmd/headscale/headscale.go
dev: lint test build
test:
@go test -coverprofile=coverage.out ./...
+test_integration:
+ go test -tags integration -timeout 30m ./...
+
coverprofile_func:
go tool cover -func=coverage.out
@@ -17,7 +20,7 @@ coverprofile_html:
lint:
golint
- golangci-lint run
+ golangci-lint run --timeout 5m
compress: build
upx --brute headscale
diff --git a/README.md b/README.md
index 5f90c831..1a60db18 100644
--- a/README.md
+++ b/README.md
@@ -18,28 +18,36 @@ Headscale implements this coordination server.
- [x] Base functionality (nodes can communicate with each other)
- [x] Node registration through the web flow
-- [x] Network changes are relied to the nodes
+- [x] Network changes are relayed to the nodes
- [x] Namespace support (~equivalent to multi-user in Tailscale.com)
- [x] Routing (advertise & accept, including exit nodes)
- [x] Node registration via pre-auth keys (including reusable keys, and ephemeral node support)
- [X] JSON-formatted output
- [X] ACLs
-- [ ] Share nodes between ~~users~~ namespaces
-- [ ] DNS
+- [X] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10)
+- [X] DNS (passing DNS servers to nodes)
+- [X] Share nodes between ~~users~~ namespaces
+- [ ] MagicDNS / Smart DNS
## Roadmap 🤷
-We are now focusing on adding integration tests with the official clients.
-
Suggestions/PRs welcomed!
## Running it
-1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH
-
+1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH or use the docker container
+
+ ```shell
+ docker pull headscale/headscale:x.x.x
+ ```
+
2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running
@@ -64,11 +72,19 @@ Suggestions/PRs welcomed!
```shell
headscale namespaces create myfirstnamespace
```
+ or docker:
+ ```shell
+ docker run -v ./private.key:/private.key -v ./config.json:/config.json headscale/headscale:x.x.x headscale namespace create myfirstnamespace
+ ```
5. Run the server
```shell
headscale serve
```
+ or docker:
+ ```shell
+ docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derb.yaml:/derb.yaml -p 127.0.0.1:8080:8080 headscale/headscale:x.x.x headscale serve
+ ```
6. If you used tailscale.com before in your nodes, make sure you clear the tailscaled data folder
```shell
@@ -88,6 +104,10 @@ Suggestions/PRs welcomed!
```shell
headscale -n myfirstnamespace node register YOURMACHINEKEY
```
+ or docker:
+ ```shell
+ docker run -v ./private.key:/private.key -v ./config.json:/config.json headscale/headscale:x.x.x headscale -n myfirstnamespace node register YOURMACHINEKEY
+ ```
Alternatively, you can use Auth Keys to register your machines:
@@ -95,6 +115,10 @@ Alternatively, you can use Auth Keys to register your machines:
```shell
headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h
```
+ or docker:
+ ```shell
+ docker run -v ./private.key:/private.key -v ./config.json:/config.json headscale/headscale:x.x.x headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h
+ ```
2. Use the authkey from your machine to register it
```shell
@@ -113,9 +137,15 @@ Headscale's configuration file is named `config.json` or `config.yaml`. Headscal
```
"server_url": "http://192.168.1.12:8080",
"listen_addr": "0.0.0.0:8080",
+ "ip_prefix": "100.64.0.0/10"
```
-`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on.
+`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on. `ip_prefix` is the IP prefix (range) in which IP addresses for nodes will be allocated (default 100.64.0.0/10, e.g., 192.168.4.0/24, 10.0.0.0/8)
+
+```
+ "log_level": "debug"
+```
+`log_level` can be used to set the Log level for Headscale, it defaults to `debug`, and the available levels are: `trace`, `debug`, `info`, `warn` and `error`.
```
"private_key_path": "private.key",
diff --git a/acls.go b/acls.go
index f4ed4c0e..fea72a7f 100644
--- a/acls.go
+++ b/acls.go
@@ -4,11 +4,12 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"os"
"strconv"
"strings"
+ "github.com/rs/zerolog/log"
+
"github.com/tailscale/hujson"
"inet.af/netaddr"
"tailscale.com/tailcfg"
@@ -66,7 +67,8 @@ func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
for j, u := range a.Users {
srcs, err := h.generateACLPolicySrcIP(u)
if err != nil {
- log.Printf("Error parsing ACL %d, User %d", i, j)
+ log.Error().
+ Msgf("Error parsing ACL %d, User %d", i, j)
return nil, err
}
srcIPs = append(srcIPs, *srcs...)
@@ -77,7 +79,8 @@ func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
for j, d := range a.Ports {
dests, err := h.generateACLPolicyDestPorts(d)
if err != nil {
- log.Printf("Error parsing ACL %d, Port %d", i, j)
+ log.Error().
+ Msgf("Error parsing ACL %d, Port %d", i, j)
return nil, err
}
destPorts = append(destPorts, *dests...)
diff --git a/api.go b/api.go
index 088c337f..e2a56185 100644
--- a/api.go
+++ b/api.go
@@ -6,15 +6,14 @@ import (
"errors"
"fmt"
"io"
- "log"
"net/http"
"time"
+ "github.com/rs/zerolog/log"
+
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
- "gorm.io/datatypes"
"gorm.io/gorm"
- "inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
)
@@ -34,8 +33,6 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
return
}
- // spew.Dump(c.Params)
-
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(fmt.Sprintf(`
@@ -63,29 +60,40 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
mKeyStr := c.Param("id")
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
- log.Printf("Cannot parse machine key: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot parse machine key")
c.String(http.StatusInternalServerError, "Sad!")
return
}
req := tailcfg.RegisterRequest{}
err = decode(body, &req, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot decode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot decode message")
c.String(http.StatusInternalServerError, "Very sad!")
return
}
+ now := time.Now().UTC()
var m Machine
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
- log.Println("New Machine!")
+ log.Info().Str("machine", req.Hostinfo.Hostname).Msg("New machine")
m = Machine{
- Expiry: &req.Expiry,
- MachineKey: mKey.HexString(),
- Name: req.Hostinfo.Hostname,
- NodeKey: wgkey.Key(req.NodeKey).HexString(),
+ Expiry: &req.Expiry,
+ MachineKey: mKey.HexString(),
+ Name: req.Hostinfo.Hostname,
+ NodeKey: wgkey.Key(req.NodeKey).HexString(),
+ LastSuccessfulUpdate: &now,
}
if err := h.db.Create(&m).Error; err != nil {
- log.Printf("Could not create row: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Could not create row")
return
}
}
@@ -100,13 +108,20 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
// We have the updated key!
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
if m.Registered {
- log.Printf("[%s] Client is registered and we have the current NodeKey. All clear to /map", m.Name)
+ log.Debug().
+ Str("handler", "Registration").
+ Str("machine", m.Name).
+ Msg("Client is registered and we have the current NodeKey. All clear to /map")
+
resp.AuthURL = ""
resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
@@ -114,12 +129,18 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
return
}
- log.Printf("[%s] Not registered and not NodeKey rotation. Sending a authurl to register", m.Name)
+ log.Debug().
+ Str("handler", "Registration").
+ Str("machine", m.Name).
+ Msg("Not registered and not NodeKey rotation. Sending a authurl to register")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString())
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
@@ -129,7 +150,10 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
- log.Printf("[%s] We have the OldNodeKey in the database. This is a key refresh", m.Name)
+ log.Debug().
+ Str("handler", "Registration").
+ Str("machine", m.Name).
+ Msg("We have the OldNodeKey in the database. This is a key refresh")
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
h.db.Save(&m)
@@ -137,7 +161,10 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "Extremely sad!")
return
}
@@ -148,213 +175,63 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
// We arrive here after a client is restarted without finalizing the authentication flow or
// when headscale is stopped in the middle of the auth process.
if m.Registered {
- log.Printf("[%s] The node is sending us a new NodeKey, but machine is registered. All clear for /map", m.Name)
+ log.Debug().
+ Str("handler", "Registration").
+ Str("machine", m.Name).
+ Msg("The node is sending us a new NodeKey, but machine is registered. All clear for /map")
resp.AuthURL = ""
resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
return
}
- log.Printf("[%s] The node is sending us a new NodeKey, sending auth url", m.Name)
+
+ log.Debug().
+ Str("handler", "Registration").
+ Str("machine", m.Name).
+ Msg("The node is sending us a new NodeKey, sending auth url")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString())
respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("handler", "Registration").
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
}
-// PollNetMapHandler takes care of /machine/:id/map
-//
-// This is the busiest endpoint, as it keeps the HTTP long poll that updates
-// the clients when something in the network changes.
-//
-// The clients POST stuff like HostInfo and their Endpoints here, but
-// only after their first request (marked with the ReadOnly field).
-//
-// At this moment the updates are sent in a quite horrendous way, but they kinda work.
-func (h *Headscale) PollNetMapHandler(c *gin.Context) {
- body, _ := io.ReadAll(c.Request.Body)
- mKeyStr := c.Param("id")
- mKey, err := wgkey.ParseHex(mKeyStr)
- if err != nil {
- log.Printf("Cannot parse client key: %s", err)
- c.String(http.StatusBadRequest, "")
- return
- }
- req := tailcfg.MapRequest{}
- err = decode(body, &req, &mKey, h.privateKey)
- if err != nil {
- log.Printf("Cannot decode message: %s", err)
- c.String(http.StatusBadRequest, "")
- return
- }
-
- var m Machine
- if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
- log.Printf("Ignoring request, cannot find machine with key %s", mKey.HexString())
- c.String(http.StatusUnauthorized, "")
- return
- }
-
- hostinfo, _ := json.Marshal(req.Hostinfo)
- m.Name = req.Hostinfo.Hostname
- m.HostInfo = datatypes.JSON(hostinfo)
- m.DiscoKey = wgkey.Key(req.DiscoKey).HexString()
- now := time.Now().UTC()
-
- // From Tailscale client:
- //
- // ReadOnly is whether the client just wants to fetch the MapResponse,
- // without updating their Endpoints. The Endpoints field will be ignored and
- // LastSeen will not be updated and peers will not be notified of changes.
- //
- // The intended use is for clients to discover the DERP map at start-up
- // before their first real endpoint update.
- if !req.ReadOnly {
- endpoints, _ := json.Marshal(req.Endpoints)
- m.Endpoints = datatypes.JSON(endpoints)
- m.LastSeen = &now
- }
- h.db.Save(&m)
-
- pollData := make(chan []byte, 1)
- update := make(chan []byte, 1)
- cancelKeepAlive := make(chan []byte, 1)
- defer close(pollData)
- defer close(cancelKeepAlive)
- h.pollMu.Lock()
- h.clientsPolling[m.ID] = update
- h.pollMu.Unlock()
-
- data, err := h.getMapResponse(mKey, req, m)
- if err != nil {
- c.String(http.StatusInternalServerError, ":(")
- return
- }
-
- // We update our peers if the client is not sending ReadOnly in the MapRequest
- // so we don't distribute its initial request (it comes with
- // empty endpoints to peers)
-
- // Details on the protocol can be found in https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L696
- log.Printf("[%s] ReadOnly=%t OmitPeers=%t Stream=%t", m.Name, req.ReadOnly, req.OmitPeers, req.Stream)
-
- if req.ReadOnly {
- log.Printf("[%s] Client is starting up. Asking for DERP map", m.Name)
- c.Data(200, "application/json; charset=utf-8", *data)
- return
- }
- if req.OmitPeers && !req.Stream {
- log.Printf("[%s] Client sent endpoint update and is ok with a response without peer list", m.Name)
- c.Data(200, "application/json; charset=utf-8", *data)
- return
- } else if req.OmitPeers && req.Stream {
- log.Printf("[%s] Warning, ignoring request, don't know how to handle it", m.Name)
- c.String(http.StatusBadRequest, "")
- return
- }
-
- log.Printf("[%s] Client is ready to access the tailnet", m.Name)
- log.Printf("[%s] Sending initial map", m.Name)
- pollData <- *data
-
- log.Printf("[%s] Notifying peers", m.Name)
- peers, _ := h.getPeers(m)
- h.pollMu.Lock()
- for _, p := range *peers {
- pUp, ok := h.clientsPolling[uint64(p.ID)]
- if ok {
- log.Printf("[%s] Notifying peer %s (%s)", m.Name, p.Name, p.Addresses[0])
- pUp <- []byte{}
- } else {
- log.Printf("[%s] Peer %s does not appear to be polling", m.Name, p.Name)
- }
- }
- h.pollMu.Unlock()
-
- go h.keepAlive(cancelKeepAlive, pollData, mKey, req, m)
-
- c.Stream(func(w io.Writer) bool {
- select {
- case data := <-pollData:
- log.Printf("[%s] Sending data (%d bytes)", m.Name, len(data))
- _, err := w.Write(data)
- if err != nil {
- log.Printf("[%s] Cannot write data: %s", m.Name, err)
- }
- now := time.Now().UTC()
- m.LastSeen = &now
- h.db.Save(&m)
- return true
-
- case <-update:
- log.Printf("[%s] Received a request for update", m.Name)
- data, err := h.getMapResponse(mKey, req, m)
- if err != nil {
- log.Printf("[%s] Could not get the map update: %s", m.Name, err)
- }
- _, err = w.Write(*data)
- if err != nil {
- log.Printf("[%s] Could not write the map response: %s", m.Name, err)
- }
- return true
-
- case <-c.Request.Context().Done():
- log.Printf("[%s] The client has closed the connection", m.Name)
- now := time.Now().UTC()
- m.LastSeen = &now
- h.db.Save(&m)
- h.pollMu.Lock()
- cancelKeepAlive <- []byte{}
- delete(h.clientsPolling, m.ID)
- close(update)
- h.pollMu.Unlock()
- return false
-
- }
- })
-}
-
-func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgkey.Key, req tailcfg.MapRequest, m Machine) {
- for {
- select {
- case <-cancel:
- return
-
- default:
- h.pollMu.Lock()
- data, err := h.getMapKeepAliveResponse(mKey, req, m)
- if err != nil {
- log.Printf("Error generating the keep alive msg: %s", err)
- return
- }
- log.Printf("[%s] Sending keepalive", m.Name)
- pollData <- *data
- h.pollMu.Unlock()
- time.Sleep(60 * time.Second)
- }
- }
-}
-
func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
- node, err := m.toNode()
+ log.Trace().
+ Str("func", "getMapResponse").
+ Str("machine", req.Hostinfo.Hostname).
+ Msg("Creating Map response")
+ node, err := m.toNode(true)
if err != nil {
- log.Printf("Cannot convert to node: %s", err)
+ log.Error().
+ Str("func", "getMapResponse").
+ Err(err).
+ Msg("Cannot convert to node")
return nil, err
}
peers, err := h.getPeers(m)
if err != nil {
- log.Printf("Cannot fetch peers: %s", err)
+ log.Error().
+ Str("func", "getMapResponse").
+ Err(err).
+ Msg("Cannot fetch peers")
return nil, err
}
@@ -365,20 +242,30 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Mac
}
resp := tailcfg.MapResponse{
- KeepAlive: false,
- Node: node,
- Peers: *peers,
- DNS: []netaddr.IP{},
+ KeepAlive: false,
+ Node: node,
+ Peers: *peers,
+ //TODO(kradalby): As per tailscale docs, if DNSConfig is nil,
+ // it means its not updated, maybe we can have some logic
+ // to check and only pass updates when its updates.
+ // This is probably more relevant if we try to implement
+ // "MagicDNS"
+ DNSConfig: h.cfg.DNSConfig,
SearchPaths: []string{},
Domain: "headscale.net",
PacketFilter: *h.aclRules,
DERPMap: h.cfg.DerpMap,
UserProfiles: []tailcfg.UserProfile{profile},
}
+ log.Trace().
+ Str("func", "getMapResponse").
+ Str("machine", req.Hostinfo.Hostname).
+ Msgf("Generated map response: %s", tailMapResponseToString(resp))
var respBody []byte
if req.Compress == "zstd" {
src, _ := json.Marshal(resp)
+
encoder, _ := zstd.NewWriter(nil)
srcCompressed := encoder.EncodeAll(src, nil)
respBody, err = encodeMsg(srcCompressed, &mKey, h.privateKey)
@@ -391,7 +278,6 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Mac
return nil, err
}
}
- // spew.Dump(resp)
// declare the incoming size on the first 4 bytes
data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(respBody)))
@@ -426,25 +312,49 @@ func (h *Headscale) getMapKeepAliveResponse(mKey wgkey.Key, req tailcfg.MapReque
}
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, req tailcfg.RegisterRequest, m Machine) {
+ log.Debug().
+ Str("func", "handleAuthKey").
+ Str("machine", req.Hostinfo.Hostname).
+ Msgf("Processing auth key for %s", req.Hostinfo.Hostname)
resp := tailcfg.RegisterResponse{}
pak, err := h.checkKeyValidity(req.Auth.AuthKey)
if err != nil {
resp.MachineAuthorized = false
respBody, err := encode(resp, &idKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
- log.Printf("[%s] Failed authentication via AuthKey", m.Name)
+ log.Error().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Msg("Failed authentication via AuthKey")
return
}
+
+ log.Debug().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Msg("Authentication key was valid, proceeding to acquire an IP address")
ip, err := h.getAvailableIP()
if err != nil {
- log.Println(err)
+ log.Error().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Msg("Failed to find an available IP")
return
}
+ log.Info().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Str("ip", ip.String()).
+ Msgf("Assigning %s to %s", ip, m.Name)
m.AuthKeyID = uint(pak.ID)
m.IPAddress = ip.String()
@@ -458,10 +368,18 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key,
resp.User = *pak.Namespace.toUser()
respBody, err := encode(resp, &idKey, h.privateKey)
if err != nil {
- log.Printf("Cannot encode message: %s", err)
+ log.Error().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Err(err).
+ Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "Extremely sad!")
return
}
c.Data(200, "application/json; charset=utf-8", respBody)
- log.Printf("[%s] Successfully authenticated via AuthKey", m.Name)
+ log.Info().
+ Str("func", "handleAuthKey").
+ Str("machine", m.Name).
+ Str("ip", ip.String()).
+ Msg("Successfully authenticated via AuthKey")
}
diff --git a/app.go b/app.go
index c4411a68..dc398eb7 100644
--- a/app.go
+++ b/app.go
@@ -3,16 +3,18 @@ package headscale
import (
"errors"
"fmt"
- "log"
"net/http"
"os"
"strings"
"sync"
"time"
+ "github.com/rs/zerolog/log"
+
"github.com/gin-gonic/gin"
"golang.org/x/crypto/acme/autocert"
"gorm.io/gorm"
+ "inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
)
@@ -24,6 +26,7 @@ type Config struct {
PrivateKeyPath string
DerpMap *tailcfg.DERPMap
EphemeralNodeInactivityTimeout time.Duration
+ IPPrefix netaddr.IPPrefix
DBtype string
DBpath string
@@ -40,6 +43,8 @@ type Config struct {
TLSCertPath string
TLSKeyPath string
+
+ DNSConfig *tailcfg.DNSConfig
}
// Headscale represents the base app of the service
@@ -55,8 +60,10 @@ type Headscale struct {
aclPolicy *ACLPolicy
aclRules *[]tailcfg.FilterRule
- pollMu sync.Mutex
- clientsPolling map[uint64]chan []byte // this is by all means a hackity hack
+ clientsUpdateChannels sync.Map
+ clientsUpdateChannelMutex sync.Mutex
+
+ lastStateChange sync.Map
}
// NewHeadscale returns the Headscale app
@@ -96,7 +103,6 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
return nil, err
}
- h.clientsPolling = make(map[uint64]chan []byte)
return &h, nil
}
@@ -106,9 +112,9 @@ func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, http.StatusFound)
}
-// ExpireEphemeralNodes deletes ephemeral machine records that have not been
+// expireEphemeralNodes deletes ephemeral machine records that have not been
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout
-func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
+func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
h.expireEphemeralNodesWorker()
@@ -118,30 +124,46 @@ func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
func (h *Headscale) expireEphemeralNodesWorker() {
namespaces, err := h.ListNamespaces()
if err != nil {
- log.Printf("Error listing namespaces: %s", err)
+ log.Error().Err(err).Msg("Error listing namespaces")
return
}
for _, ns := range *namespaces {
machines, err := h.ListMachinesInNamespace(ns.Name)
if err != nil {
- log.Printf("Error listing machines in namespace %s: %s", ns.Name, err)
+ log.Error().Err(err).Str("namespace", ns.Name).Msg("Error listing machines in namespace")
return
}
for _, m := range *machines {
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
- log.Printf("[%s] Ephemeral client removed from database\n", m.Name)
+ log.Info().Str("machine", m.Name).Msg("Ephemeral client removed from database")
err = h.db.Unscoped().Delete(m).Error
if err != nil {
- log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err)
+ log.Error().Err(err).Str("machine", m.Name).Msg("🤮 Cannot delete ephemeral machine from the database")
}
+ h.notifyChangesToPeers(&m)
}
}
}
}
+// WatchForKVUpdates checks the KV DB table for requests to perform tailnet upgrades
+// This is a way to communitate the CLI with the headscale server
+func (h *Headscale) watchForKVUpdates(milliSeconds int64) {
+ ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
+ for range ticker.C {
+ h.watchForKVUpdatesWorker()
+ }
+}
+
+func (h *Headscale) watchForKVUpdatesWorker() {
+ h.checkForNamespacesPendingUpdates()
+ // more functions will come here in the future
+}
+
// Serve launches a GIN server with the Headscale API
func (h *Headscale) Serve() error {
r := gin.Default()
+ r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"healthy": "ok"}) })
r.GET("/key", h.KeyHandler)
r.GET("/register", h.RegisterWebAPI)
r.POST("/machine/:id/map", h.PollNetMapHandler)
@@ -149,9 +171,22 @@ func (h *Headscale) Serve() error {
r.GET("/apple", h.AppleMobileConfig)
r.GET("/apple/:platform", h.ApplePlatformConfig)
var err error
+
+ timeout := 30 * time.Second
+
+ go h.watchForKVUpdates(5000)
+ go h.expireEphemeralNodes(5000)
+
+ s := &http.Server{
+ Addr: h.cfg.Addr,
+ Handler: r,
+ ReadTimeout: timeout,
+ WriteTimeout: timeout,
+ }
+
if h.cfg.TLSLetsEncryptHostname != "" {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
- log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
+ log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
}
m := autocert.Manager{
@@ -160,9 +195,11 @@ func (h *Headscale) Serve() error {
Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir),
}
s := &http.Server{
- Addr: h.cfg.Addr,
- TLSConfig: m.TLSConfig(),
- Handler: r,
+ Addr: h.cfg.Addr,
+ TLSConfig: m.TLSConfig(),
+ Handler: r,
+ ReadTimeout: timeout,
+ WriteTimeout: timeout,
}
if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" {
// Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737)
@@ -174,7 +211,10 @@ func (h *Headscale) Serve() error {
// port 80 for the certificate validation in addition to the headscale
// service, which can be configured to run on any other port.
go func() {
- log.Fatal(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, m.HTTPHandler(http.HandlerFunc(h.redirect))))
+
+ log.Fatal().
+ Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, m.HTTPHandler(http.HandlerFunc(h.redirect)))).
+ Msg("failed to set up a HTTP server")
}()
err = s.ListenAndServeTLS("", "")
} else {
@@ -182,14 +222,31 @@ func (h *Headscale) Serve() error {
}
} else if h.cfg.TLSCertPath == "" {
if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
- log.Println("WARNING: listening without TLS but ServerURL does not start with http://")
+ log.Warn().Msg("Listening without TLS but ServerURL does not start with http://")
}
- err = r.Run(h.cfg.Addr)
+ err = s.ListenAndServe()
} else {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
- log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
+ log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
}
- err = r.RunTLS(h.cfg.Addr, h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
+ err = s.ListenAndServeTLS(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
}
return err
}
+
+func (h *Headscale) setLastStateChangeToNow(namespace string) {
+ now := time.Now().UTC()
+ h.lastStateChange.Store(namespace, now)
+}
+
+func (h *Headscale) getLastStateChange(namespace string) time.Time {
+ if wrapped, ok := h.lastStateChange.Load(namespace); ok {
+ lastChange, _ := wrapped.(time.Time)
+ return lastChange
+
+ }
+
+ now := time.Now().UTC()
+ h.lastStateChange.Store(namespace, now)
+ return now
+}
diff --git a/app_test.go b/app_test.go
index ad633334..5e53f1cc 100644
--- a/app_test.go
+++ b/app_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"gopkg.in/check.v1"
+ "inet.af/netaddr"
)
func Test(t *testing.T) {
@@ -36,7 +37,9 @@ func (s *Suite) ResetDB(c *check.C) {
if err != nil {
c.Fatal(err)
}
- cfg := Config{}
+ cfg := Config{
+ IPPrefix: netaddr.MustParseIPPrefix("10.27.0.0/23"),
+ }
h = Headscale{
cfg: cfg,
diff --git a/cli_test.go b/cli_test.go
index 9616b4a2..528a115e 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -15,6 +15,7 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
+ IPAddress: "10.0.0.1",
}
h.db.Save(&m)
diff --git a/cmd/headscale/cli/namespaces.go b/cmd/headscale/cli/namespaces.go
index 7d3977f9..c35c02cc 100644
--- a/cmd/headscale/cli/namespaces.go
+++ b/cmd/headscale/cli/namespaces.go
@@ -3,8 +3,10 @@ package cli
import (
"fmt"
"log"
+ "strconv"
"strings"
+ "github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@@ -94,9 +96,14 @@ var listNamespacesCmd = &cobra.Command{
fmt.Println(err)
return
}
- fmt.Printf("ID\tName\n")
+
+ d := pterm.TableData{{"ID", "Name", "Created"}}
for _, n := range *namespaces {
- fmt.Printf("%d\t%s\n", n.ID, n.Name)
+ d = append(d, []string{strconv.FormatUint(uint64(n.ID), 10), n.Name, n.CreatedAt.Format("2006-01-02 15:04:05")})
+ }
+ err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
+ if err != nil {
+ log.Fatal(err)
}
},
}
diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go
index 75000434..5f30dc1b 100644
--- a/cmd/headscale/cli/nodes.go
+++ b/cmd/headscale/cli/nodes.go
@@ -8,7 +8,11 @@ import (
"time"
survey "github.com/AlecAivazis/survey/v2"
+ "github.com/juanfont/headscale"
+ "github.com/pterm/pterm"
"github.com/spf13/cobra"
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/wgkey"
)
func init() {
@@ -21,6 +25,7 @@ func init() {
nodeCmd.AddCommand(listNodesCmd)
nodeCmd.AddCommand(registerNodeCmd)
nodeCmd.AddCommand(deleteNodeCmd)
+ nodeCmd.AddCommand(shareMachineCmd)
}
var nodeCmd = &cobra.Command{
@@ -33,7 +38,7 @@ var registerNodeCmd = &cobra.Command{
Short: "Registers a machine to your network",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
- return fmt.Errorf("Missing parameters")
+ return fmt.Errorf("missing parameters")
}
return nil
},
@@ -75,9 +80,26 @@ var listNodesCmd = &cobra.Command{
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
+
+ namespace, err := h.GetNamespace(n)
+ if err != nil {
+ log.Fatalf("Error fetching namespace: %s", err)
+ }
+
machines, err := h.ListMachinesInNamespace(n)
+ if err != nil {
+ log.Fatalf("Error fetching machines: %s", err)
+ }
+
+ sharedMachines, err := h.ListSharedMachinesInNamespace(n)
+ if err != nil {
+ log.Fatalf("Error fetching shared machines: %s", err)
+ }
+
+ allMachines := append(*machines, *sharedMachines...)
+
if strings.HasPrefix(o, "json") {
- JsonOutput(machines, err, o)
+ JsonOutput(allMachines, err, o)
return
}
@@ -85,19 +107,15 @@ var listNodesCmd = &cobra.Command{
log.Fatalf("Error getting nodes: %s", err)
}
- fmt.Printf("ID\tname\t\tlast seen\t\tephemeral\n")
- for _, m := range *machines {
- var ephemeral bool
- if m.AuthKey != nil && m.AuthKey.Ephemeral {
- ephemeral = true
- }
- var lastSeen time.Time
- if m.LastSeen != nil {
- lastSeen = *m.LastSeen
- }
- fmt.Printf("%d\t%s\t%s\t%t\n", m.ID, m.Name, lastSeen.Format("2006-01-02 15:04:05"), ephemeral)
+ d, err := nodesToPtables(*namespace, allMachines)
+ if err != nil {
+ log.Fatalf("Error converting to table: %s", err)
}
+ err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
+ if err != nil {
+ log.Fatal(err)
+ }
},
}
@@ -106,7 +124,7 @@ var deleteNodeCmd = &cobra.Command{
Short: "Delete a node",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
- return fmt.Errorf("Missing parameters")
+ return fmt.Errorf("missing parameters")
}
return nil
},
@@ -144,3 +162,95 @@ var deleteNodeCmd = &cobra.Command{
}
},
}
+
+var shareMachineCmd = &cobra.Command{
+ Use: "share ID namespace",
+ Short: "Shares a node from the current namespace to the specified one",
+ Args: func(cmd *cobra.Command, args []string) error {
+ if len(args) < 2 {
+ return fmt.Errorf("missing parameters")
+ }
+ return nil
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ namespace, err := cmd.Flags().GetString("namespace")
+ if err != nil {
+ log.Fatalf("Error getting namespace: %s", err)
+ }
+ output, _ := cmd.Flags().GetString("output")
+
+ h, err := getHeadscaleApp()
+ if err != nil {
+ log.Fatalf("Error initializing: %s", err)
+ }
+
+ _, err = h.GetNamespace(namespace)
+ if err != nil {
+ log.Fatalf("Error fetching origin namespace: %s", err)
+ }
+
+ destinationNamespace, err := h.GetNamespace(args[1])
+ if err != nil {
+ log.Fatalf("Error fetching destination namespace: %s", err)
+ }
+
+ id, err := strconv.Atoi(args[0])
+ if err != nil {
+ log.Fatalf("Error converting ID to integer: %s", err)
+ }
+ machine, err := h.GetMachineByID(uint64(id))
+ if err != nil {
+ log.Fatalf("Error getting node: %s", err)
+ }
+
+ err = h.AddSharedMachineToNamespace(machine, destinationNamespace)
+ if strings.HasPrefix(output, "json") {
+ JsonOutput(map[string]string{"Result": "Node shared"}, err, output)
+ return
+ }
+ if err != nil {
+ fmt.Printf("Error sharing node: %s\n", err)
+ return
+ }
+
+ fmt.Println("Node shared!")
+ },
+}
+
+func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.Machine) (pterm.TableData, error) {
+ d := pterm.TableData{{"ID", "Name", "NodeKey", "Namespace", "IP address", "Ephemeral", "Last seen", "Online"}}
+
+ for _, machine := range machines {
+ var ephemeral bool
+ if machine.AuthKey != nil && machine.AuthKey.Ephemeral {
+ ephemeral = true
+ }
+ var lastSeen time.Time
+ var lastSeenTime string
+ if machine.LastSeen != nil {
+ lastSeen = *machine.LastSeen
+ lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
+ }
+ nKey, err := wgkey.ParseHex(machine.NodeKey)
+ if err != nil {
+ return nil, err
+ }
+ nodeKey := tailcfg.NodeKey(nKey)
+
+ var online string
+ if lastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online
+ online = pterm.LightGreen("true")
+ } else {
+ online = pterm.LightRed("false")
+ }
+
+ var namespace string
+ if currentNamespace.ID == machine.NamespaceID {
+ namespace = pterm.LightMagenta(machine.Namespace.Name)
+ } else {
+ namespace = pterm.LightYellow(machine.Namespace.Name)
+ }
+ d = append(d, []string{strconv.FormatUint(machine.ID, 10), machine.Name, nodeKey.ShortString(), namespace, machine.IPAddress, strconv.FormatBool(ephemeral), lastSeenTime, online})
+ }
+ return d, nil
+}
diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go
index eb9d182c..1340267e 100644
--- a/cmd/headscale/cli/preauthkeys.go
+++ b/cmd/headscale/cli/preauthkeys.go
@@ -3,10 +3,12 @@ package cli
import (
"fmt"
"log"
+ "strconv"
"strings"
"time"
"github.com/hako/durafmt"
+ "github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@@ -19,6 +21,7 @@ func init() {
}
preauthkeysCmd.AddCommand(listPreAuthKeys)
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
+ preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
createPreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
createPreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
createPreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")
@@ -53,6 +56,8 @@ var listPreAuthKeys = &cobra.Command{
fmt.Printf("Error getting the list of keys: %s\n", err)
return
}
+
+ d := pterm.TableData{{"ID", "Key", "Reusable", "Ephemeral", "Expiration", "Created"}}
for _, k := range *keys {
expiration := "-"
if k.Expiration != nil {
@@ -66,15 +71,19 @@ var listPreAuthKeys = &cobra.Command{
reusable = fmt.Sprintf("%v", k.Reusable)
}
- fmt.Printf(
- "key: %s, namespace: %s, reusable: %s, ephemeral: %v, expiration: %s, created_at: %s\n",
+ d = append(d, []string{
+ strconv.FormatUint(k.ID, 10),
k.Key,
- k.Namespace.Name,
reusable,
- k.Ephemeral,
+ strconv.FormatBool(k.Ephemeral),
expiration,
k.CreatedAt.Format("2006-01-02 15:04:05"),
- )
+ })
+
+ }
+ err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
+ if err != nil {
+ log.Fatal(err)
}
},
}
@@ -116,6 +125,45 @@ var createPreAuthKeyCmd = &cobra.Command{
fmt.Println(err)
return
}
- fmt.Printf("Key: %s\n", k.Key)
+ fmt.Printf("%s\n", k.Key)
+ },
+}
+
+var expirePreAuthKeyCmd = &cobra.Command{
+ Use: "expire",
+ Short: "Expire a preauthkey",
+ Args: func(cmd *cobra.Command, args []string) error {
+ if len(args) < 1 {
+ return fmt.Errorf("missing parameters")
+ }
+ return nil
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ n, err := cmd.Flags().GetString("namespace")
+ if err != nil {
+ log.Fatalf("Error getting namespace: %s", err)
+ }
+ o, _ := cmd.Flags().GetString("output")
+
+ h, err := getHeadscaleApp()
+ if err != nil {
+ log.Fatalf("Error initializing: %s", err)
+ }
+
+ k, err := h.GetPreAuthKey(n, args[0])
+ if err != nil {
+ log.Fatalf("Error getting the key: %s", err)
+ }
+
+ err = h.MarkExpirePreAuthKey(k)
+ if strings.HasPrefix(o, "json") {
+ JsonOutput(k, err, o)
+ return
+ }
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ fmt.Println("Expired")
},
}
diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go
index 0dd68064..21857d85 100644
--- a/cmd/headscale/cli/root.go
+++ b/cmd/headscale/cli/root.go
@@ -2,8 +2,9 @@ package cli
import (
"fmt"
- "github.com/spf13/cobra"
"os"
+
+ "github.com/spf13/cobra"
)
func init() {
@@ -16,8 +17,7 @@ var rootCmd = &cobra.Command{
Long: `
headscale is an open source implementation of the Tailscale control server
-Juan Font Alonso - 2021
-https://gitlab.com/juanfont/headscale`,
+https://github.com/juanfont/headscale`,
}
func Execute() {
diff --git a/cmd/headscale/cli/routes.go b/cmd/headscale/cli/routes.go
index 98b653f9..72010864 100644
--- a/cmd/headscale/cli/routes.go
+++ b/cmd/headscale/cli/routes.go
@@ -5,6 +5,7 @@ import (
"log"
"strings"
+ "github.com/pterm/pterm"
"github.com/spf13/cobra"
)
@@ -15,6 +16,9 @@ func init() {
if err != nil {
log.Fatalf(err.Error())
}
+
+ enableRouteCmd.Flags().BoolP("all", "a", false, "Enable all routes advertised by the node")
+
routesCmd.AddCommand(listRoutesCmd)
routesCmd.AddCommand(enableRouteCmd)
}
@@ -44,19 +48,25 @@ var listRoutesCmd = &cobra.Command{
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
- routes, err := h.GetNodeRoutes(n, args[0])
-
- if strings.HasPrefix(o, "json") {
- JsonOutput(routes, err, o)
- return
- }
+ availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
if err != nil {
fmt.Println(err)
return
}
- fmt.Println(routes)
+ if strings.HasPrefix(o, "json") {
+ // TODO: Add enable/disabled information to this interface
+ JsonOutput(availableRoutes, err, o)
+ return
+ }
+
+ d := h.RoutesToPtables(n, args[0], *availableRoutes)
+
+ err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
+ if err != nil {
+ log.Fatal(err)
+ }
},
}
@@ -64,32 +74,74 @@ var enableRouteCmd = &cobra.Command{
Use: "enable node-name route",
Short: "Allows exposing a route declared by this node to the rest of the nodes",
Args: func(cmd *cobra.Command, args []string) error {
- if len(args) < 2 {
- return fmt.Errorf("Missing parameters")
+ all, err := cmd.Flags().GetBool("all")
+ if err != nil {
+ log.Fatalf("Error getting namespace: %s", err)
+ }
+
+ if all {
+ if len(args) < 1 {
+ return fmt.Errorf("Missing parameters")
+ }
+ return nil
+ } else {
+ if len(args) < 2 {
+ return fmt.Errorf("Missing parameters")
+ }
+ return nil
}
- return nil
},
Run: func(cmd *cobra.Command, args []string) {
n, err := cmd.Flags().GetString("namespace")
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
+
o, _ := cmd.Flags().GetString("output")
+ all, err := cmd.Flags().GetBool("all")
+ if err != nil {
+ log.Fatalf("Error getting namespace: %s", err)
+ }
+
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
- route, err := h.EnableNodeRoute(n, args[0], args[1])
- if strings.HasPrefix(o, "json") {
- JsonOutput(route, err, o)
- return
- }
- if err != nil {
- fmt.Println(err)
- return
+ if all {
+ availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ for _, availableRoute := range *availableRoutes {
+ err = h.EnableNodeRoute(n, args[0], availableRoute.String())
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+
+ if strings.HasPrefix(o, "json") {
+ JsonOutput(availableRoute, err, o)
+ } else {
+ fmt.Printf("Enabled route %s\n", availableRoute)
+ }
+ }
+ } else {
+ err = h.EnableNodeRoute(n, args[0], args[1])
+
+ if strings.HasPrefix(o, "json") {
+ JsonOutput(args[1], err, o)
+ return
+ }
+
+ if err != nil {
+ fmt.Println(err)
+ return
+ }
+ fmt.Printf("Enabled route %s\n", args[1])
}
- fmt.Printf("Enabled route %s\n", route)
},
}
diff --git a/cmd/headscale/cli/server.go b/cmd/headscale/cli/server.go
index 1f8db6a2..6d9ad194 100644
--- a/cmd/headscale/cli/server.go
+++ b/cmd/headscale/cli/server.go
@@ -21,7 +21,7 @@ var serveCmd = &cobra.Command{
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
- go h.ExpireEphemeralNodes(5000)
+
err = h.Serve()
if err != nil {
log.Fatalf("Error initializing: %s", err)
diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go
index 5e47d157..aaf994d0 100644
--- a/cmd/headscale/cli/utils.go
+++ b/cmd/headscale/cli/utils.go
@@ -5,15 +5,16 @@ import (
"errors"
"fmt"
"io"
- "log"
"os"
"path/filepath"
"strings"
"time"
"github.com/juanfont/headscale"
+ "github.com/rs/zerolog/log"
"github.com/spf13/viper"
"gopkg.in/yaml.v2"
+ "inet.af/netaddr"
"tailscale.com/tailcfg"
)
@@ -36,6 +37,12 @@ func LoadConfig(path string) error {
viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
+ viper.SetDefault("ip_prefix", "100.64.0.0/10")
+
+ viper.SetDefault("log_level", "info")
+
+ viper.SetDefault("dns_config", nil)
+
err := viper.ReadInConfig()
if err != nil {
return fmt.Errorf("Fatal error reading config file: %s \n", err)
@@ -49,7 +56,8 @@ func LoadConfig(path string) error {
if (viper.GetString("tls_letsencrypt_hostname") != "") && (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
- log.Println("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
+ log.Warn().
+ Msg("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
}
if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") && (viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
@@ -64,6 +72,45 @@ func LoadConfig(path string) error {
} else {
return nil
}
+
+}
+
+func GetDNSConfig() *tailcfg.DNSConfig {
+ if viper.IsSet("dns_config") {
+ dnsConfig := &tailcfg.DNSConfig{}
+
+ if viper.IsSet("dns_config.nameservers") {
+ nameserversStr := viper.GetStringSlice("dns_config.nameservers")
+
+ nameservers := make([]netaddr.IP, len(nameserversStr))
+ resolvers := make([]tailcfg.DNSResolver, len(nameserversStr))
+
+ for index, nameserverStr := range nameserversStr {
+ nameserver, err := netaddr.ParseIP(nameserverStr)
+ if err != nil {
+ log.Error().
+ Str("func", "getDNSConfig").
+ Err(err).
+ Msgf("Could not parse nameserver IP: %s", nameserverStr)
+ }
+
+ nameservers[index] = nameserver
+ resolvers[index] = tailcfg.DNSResolver{
+ Addr: nameserver.String(),
+ }
+ }
+
+ dnsConfig.Nameservers = nameservers
+ dnsConfig.Resolvers = resolvers
+ }
+ if viper.IsSet("dns_config.domains") {
+ dnsConfig.Domains = viper.GetStringSlice("dns_config.domains")
+ }
+
+ return dnsConfig
+ }
+
+ return nil
}
func absPath(path string) string {
@@ -79,9 +126,13 @@ func absPath(path string) string {
}
func getHeadscaleApp() (*headscale.Headscale, error) {
- derpMap, err := loadDerpMap(absPath(viper.GetString("derp_map_path")))
+ derpPath := absPath(viper.GetString("derp_map_path"))
+ derpMap, err := loadDerpMap(derpPath)
if err != nil {
- log.Printf("Could not load DERP servers map file: %s", err)
+ log.Error().
+ Str("path", derpPath).
+ Err(err).
+ Msg("Could not load DERP servers map file")
}
// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
@@ -97,6 +148,7 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
Addr: viper.GetString("listen_addr"),
PrivateKeyPath: absPath(viper.GetString("private_key_path")),
DerpMap: derpMap,
+ IPPrefix: netaddr.MustParseIPPrefix(viper.GetString("ip_prefix")),
EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"),
@@ -115,6 +167,8 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
TLSCertPath: absPath(viper.GetString("tls_cert_path")),
TLSKeyPath: absPath(viper.GetString("tls_key_path")),
+
+ DNSConfig: GetDNSConfig(),
}
h, err := headscale.NewHeadscale(cfg)
@@ -125,9 +179,13 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
// We are doing this here, as in the future could be cool to have it also hot-reload
if viper.GetString("acl_policy_path") != "" {
- err = h.LoadACLPolicy(absPath(viper.GetString("acl_policy_path")))
+ aclPath := absPath(viper.GetString("acl_policy_path"))
+ err = h.LoadACLPolicy(aclPath)
if err != nil {
- log.Printf("Could not load the ACL policy: %s", err)
+ log.Error().
+ Str("path", aclPath).
+ Err(err).
+ Msg("Could not load the ACL policy")
}
}
@@ -157,24 +215,24 @@ func JsonOutput(result interface{}, errResult error, outputFormat string) {
if errResult != nil {
j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t")
if err != nil {
- log.Fatalln(err)
+ log.Fatal().Err(err)
}
} else {
j, err = json.MarshalIndent(result, "", "\t")
if err != nil {
- log.Fatalln(err)
+ log.Fatal().Err(err)
}
}
case "json-line":
if errResult != nil {
j, err = json.Marshal(ErrorOutput{errResult.Error()})
if err != nil {
- log.Fatalln(err)
+ log.Fatal().Err(err)
}
} else {
j, err = json.Marshal(result)
if err != nil {
- log.Fatalln(err)
+ log.Fatal().Err(err)
}
}
}
diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go
index c7b834c2..e4334e4f 100644
--- a/cmd/headscale/headscale.go
+++ b/cmd/headscale/headscale.go
@@ -1,15 +1,62 @@
package main
import (
- "log"
+ "os"
+ "time"
+ "github.com/efekarakus/termcolor"
"github.com/juanfont/headscale/cmd/headscale/cli"
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+ "github.com/spf13/viper"
)
func main() {
+ var colors bool
+ switch l := termcolor.SupportLevel(os.Stderr); l {
+ case termcolor.Level16M:
+ colors = true
+ case termcolor.Level256:
+ colors = true
+ case termcolor.LevelBasic:
+ colors = true
+ default:
+ // no color, return text as is.
+ colors = false
+ }
+
+ // Adhere to no-color.org manifesto of allowing users to
+ // turn off color in cli/services
+ if _, noColorIsSet := os.LookupEnv("NO_COLOR"); noColorIsSet {
+ colors = false
+ }
+
+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
+ log.Logger = log.Output(zerolog.ConsoleWriter{
+ Out: os.Stdout,
+ TimeFormat: time.RFC3339,
+ NoColor: !colors,
+ })
+
err := cli.LoadConfig("")
if err != nil {
- log.Fatalf(err.Error())
+ log.Fatal().Err(err)
+ }
+
+ logLevel := viper.GetString("log_level")
+ switch logLevel {
+ case "trace":
+ zerolog.SetGlobalLevel(zerolog.TraceLevel)
+ case "debug":
+ zerolog.SetGlobalLevel(zerolog.DebugLevel)
+ case "info":
+ zerolog.SetGlobalLevel(zerolog.InfoLevel)
+ case "warn":
+ zerolog.SetGlobalLevel(zerolog.WarnLevel)
+ case "error":
+ zerolog.SetGlobalLevel(zerolog.ErrorLevel)
+ default:
+ zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
cli.Execute()
diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go
index 8fcf8a54..58bf5899 100644
--- a/cmd/headscale/headscale_test.go
+++ b/cmd/headscale/headscale_test.go
@@ -58,7 +58,7 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) {
c.Assert(viper.GetString("db_port"), check.Equals, "5432")
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http")
- c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
+ c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
}
func (*Suite) TestSqliteConfigLoading(c *check.C) {
@@ -92,6 +92,37 @@ func (*Suite) TestSqliteConfigLoading(c *check.C) {
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http")
c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
+ c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
+}
+
+func (*Suite) TestDNSConfigLoading(c *check.C) {
+ tmpDir, err := ioutil.TempDir("", "headscale")
+ if err != nil {
+ c.Fatal(err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ path, err := os.Getwd()
+ if err != nil {
+ c.Fatal(err)
+ }
+
+ // Symlink the example config file
+ err = os.Symlink(filepath.Clean(path+"/../../config.json.sqlite.example"), filepath.Join(tmpDir, "config.json"))
+ if err != nil {
+ c.Fatal(err)
+ }
+
+ // Load example config, it should load without validation errors
+ err = cli.LoadConfig(tmpDir)
+ c.Assert(err, check.IsNil)
+
+ dnsConfig := cli.GetDNSConfig()
+ fmt.Println(dnsConfig)
+
+ c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1")
+
+ c.Assert(dnsConfig.Resolvers[0].Addr, check.Equals, "1.1.1.1")
}
func writeConfig(c *check.C, tmpDir string, configYaml []byte) {
diff --git a/config.json.postgres.example b/config.json.postgres.example
index fe772d73..aba72063 100644
--- a/config.json.postgres.example
+++ b/config.json.postgres.example
@@ -16,5 +16,10 @@
"tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "",
"tls_key_path": "",
- "acl_policy_path": ""
+ "acl_policy_path": "",
+ "dns_config": {
+ "nameservers": [
+ "1.1.1.1"
+ ]
+ }
}
diff --git a/config.json.sqlite.example b/config.json.sqlite.example
index e965059a..b22e5ace 100644
--- a/config.json.sqlite.example
+++ b/config.json.sqlite.example
@@ -12,5 +12,10 @@
"tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "",
"tls_key_path": "",
- "acl_policy_path": ""
+ "acl_policy_path": "",
+ "dns_config": {
+ "nameservers": [
+ "1.1.1.1"
+ ]
+ }
}
diff --git a/db.go b/db.go
index 6a057e16..42c5eee9 100644
--- a/db.go
+++ b/db.go
@@ -44,6 +44,11 @@ func (h *Headscale) initDB() error {
return err
}
+ err = db.AutoMigrate(&SharedMachine{})
+ if err != nil {
+ return err
+ }
+
err = h.setValue("db_version", dbVersion)
return err
}
@@ -79,6 +84,7 @@ func (h *Headscale) openDB() (*gorm.DB, error) {
return db, nil
}
+// getValue returns the value for the given key in KV
func (h *Headscale) getValue(key string) (string, error) {
var row KV
if result := h.db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -87,6 +93,7 @@ func (h *Headscale) getValue(key string) (string, error) {
return row.Value, nil
}
+// setValue sets value for the given key in KV
func (h *Headscale) setValue(key string, value string) error {
kv := KV{
Key: key,
diff --git a/derp.yaml b/derp.yaml
index 17bfc186..9434e712 100644
--- a/derp.yaml
+++ b/derp.yaml
@@ -1,7 +1,7 @@
# This file contains some of the official Tailscale DERP servers,
# shamelessly taken from https://github.com/tailscale/tailscale/blob/main/net/dnsfallback/dns-fallback-servers.json
#
-# If you plan to somehow use headscale, please deploy your own DERP infra
+# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
regions:
1:
regionid: 1
diff --git a/go.mod b/go.mod
index 88c314f6..5e125009 100644
--- a/go.mod
+++ b/go.mod
@@ -4,16 +4,31 @@ go 1.16
require (
github.com/AlecAivazis/survey/v2 v2.0.5
+ github.com/Microsoft/go-winio v0.5.0 // indirect
+ github.com/cenkalti/backoff/v4 v4.1.1 // indirect
+ github.com/containerd/continuity v0.1.0 // indirect
+ github.com/docker/cli v20.10.8+incompatible // indirect
+ github.com/docker/docker v20.10.8+incompatible // indirect
+ github.com/efekarakus/termcolor v1.0.1
github.com/gin-gonic/gin v1.7.2
github.com/gofrs/uuid v4.0.0+incompatible // indirect
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
github.com/klauspost/compress v1.13.1
github.com/lib/pq v1.10.2 // indirect
github.com/mattn/go-sqlite3 v1.14.7 // indirect
+ github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
+ github.com/opencontainers/runc v1.0.1 // indirect
+ github.com/ory/dockertest/v3 v3.7.0
+ github.com/pterm/pterm v0.12.29
+ github.com/rs/zerolog v1.23.0
github.com/spf13/cobra v1.1.3
github.com/spf13/viper v1.8.1
+ github.com/stretchr/testify v1.7.0 // indirect
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2
+ github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
+ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
+ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.0.1
diff --git a/go.sum b/go.sum
index 88cb0773..6d581ae7 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,5 @@
4d63.com/gochecknoglobals v0.0.0-20201008074935-acfc0b28355a/go.mod h1:wfdC5ZjKSPr7CybKEcgJhUOgeAQW1+7WcyK8OvUilfo=
+bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -40,11 +41,16 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.0.5 h1:xpZp+Q55wi5C7Iaze+40onHnEkex1jSc34CltJjOoPM=
github.com/AlecAivazis/survey/v2 v2.0.5/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74=
+github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
+github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Djarvur/go-err113 v0.0.0-20200511133814-5174e21577d5/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
+github.com/MarvinJWendt/testza v0.1.0 h1:4m+JkB/4e0nUlXdIa10Mg0poUz9CanQKjB3L+xecjAo=
+github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
@@ -55,8 +61,12 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.15/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
+github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU=
+github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
+github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
+github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
@@ -79,10 +89,13 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
+github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
+github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.38.52/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
@@ -91,20 +104,27 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
github.com/bombsimon/wsl/v3 v3.1.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e/go.mod h1:oDpT4efm8tSYHXV5tHSdRvBet/b/QzxZ+XyyPehvm3A=
+github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
+github.com/cenkalti/backoff/v4 v4.1.0/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ=
+github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cilium/ebpf v0.5.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
+github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
@@ -114,7 +134,12 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
+github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
+github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
+github.com/containerd/continuity v0.1.0 h1:UFRRY5JemiAhPZrr/uE0n8fMTLcZsUvySPr1+D7pgr8=
+github.com/containerd/continuity v0.1.0/go.mod h1:ICJu0PwR54nI0yPEnJ6jcS+J7CZAUXrLh8lPo2knzsM=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -128,8 +153,10 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
-github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
+github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
github.com/daixiang0/gci v0.2.4/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4=
github.com/daixiang0/gci v0.2.7/go.mod h1:+4dZ7TISfSmqfAGv59ePaHfNzgGtIkHAhhdKggP1JAc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -140,12 +167,25 @@ github.com/denisenkom/go-mssqldb v0.9.0 h1:RSohk2RsiZqLZ0zCjtfn3S4Gp4exhpBWHyQ7D
github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/docker/cli v20.10.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v20.10.8+incompatible h1:/zO/6y9IOpcehE49yMRTV9ea0nBpb8OeqSskXLNfH1E=
+github.com/docker/cli v20.10.8+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/docker v20.10.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v20.10.8+incompatible h1:RVqD337BgQicVCzYrrlhLDWhq6OAD2PJDUg2LsEUvKM=
+github.com/docker/docker v20.10.8+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
+github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
+github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
+github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/efekarakus/termcolor v1.0.1 h1:YAKFO3bnLrqZGTWyNLcYoSIAQFKVOmbqmDnwsU/znzg=
+github.com/efekarakus/termcolor v1.0.1/go.mod h1:AitrZNrE4nPO538fRsqf+p0WgLdAsGN5pUNrHEPsEMM=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -225,6 +265,7 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -314,11 +355,15 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/rpmpack v0.0.0-20201206194719-59e495f2b7e1/go.mod h1:+y9lKiqDhR4zkLl+V9h4q0rdyrYVsWWm6LLCQP33DIk=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
+github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gookit/color v1.3.1/go.mod h1:R3ogXq2B9rTbXoSHJ1HyUVAZ3poOJHpd9nQmyGZsfvQ=
+github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
+github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/goreleaser/chglog v0.1.2/go.mod h1:tTZsFuSZK4epDXfjMkxzcGbrIOXprf0JFp47BjIr3B8=
@@ -328,6 +373,7 @@ github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE=
github.com/gostaticanalysis/analysisutil v0.0.3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE=
@@ -376,6 +422,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
+github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
@@ -502,6 +550,7 @@ github.com/kunwardeep/paralleltest v1.0.2/go.mod h1:ZPqNm1fVHPllh5LPVujzbVz1JN2G
github.com/kyoh86/exportloopref v0.1.8/go.mod h1:1tUcJeiioIs7VWe5gcOObrux3lb66+sBqGZrRkMwPgg=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/lib/pq v0.0.0-20180327071824-d34b9ff171c2/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -514,6 +563,7 @@ github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQ
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
@@ -534,10 +584,13 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
@@ -579,6 +632,10 @@ github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxd
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/moby/sys/mountinfo v0.4.1/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A=
+github.com/moby/term v0.0.0-20201216013528-df9cb8a40635/go.mod h1:FBS0z0QWA44HXygs7VXDUOGoN/1TV3RuWkLO04am3wc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
+github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -587,6 +644,7 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/moricho/tparallel v0.2.1/go.mod h1:fXEIZxG2vdfl0ZF8b42f5a78EhjjD5mX8qUplsoSU4k=
github.com/mozilla/tls-observatory v0.0.0-20200317151703-4fa42e1c2dee/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk=
+github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.0/go.mod h1:dI314BppzXjJ4HsCnbo7XzrJHPszZsjnk5wEBSYHI2c=
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
@@ -613,6 +671,16 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
+github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI=
+github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
+github.com/opencontainers/runc v1.0.0-rc9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
+github.com/opencontainers/runc v1.0.1 h1:G18PGckGdAm3yVQRWDVQ1rLSLntiniKJ0cNRT2Tm5gs=
+github.com/opencontainers/runc v1.0.1/go.mod h1:aTaHFFwQXuA71CiyxOdFFIorAoemI04suvGRQFzWTD0=
+github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8=
github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
@@ -621,6 +689,8 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxS
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
+github.com/ory/dockertest/v3 v3.7.0 h1:Bijzonc69Ont3OU0a3TWKJ1Rzlh3TsDXP1JrTAkSmsM=
+github.com/ory/dockertest/v3 v3.7.0/go.mod h1:PvCCgnP7AfBZeVrzwiUTjZx/IUXlGLC1zQlUQrLIlUE=
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
@@ -670,12 +740,17 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
+github.com/pterm/pterm v0.12.29 h1:wWRNFkC3+fk/agzHIO4aaXtQuRYdXJKngP3ed+LZlMU=
+github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI=
github.com/quasilyte/go-ruleguard v0.2.0/go.mod h1:2RT/tf0Ce0UDj5y243iWKosQogJd8+1G3Rs2fxmlYnw=
github.com/quasilyte/go-ruleguard v0.2.1/go.mod h1:hN2rVc/uS4bQhQKTio2XaSJSafJwqBUWWwtssT3cQmc=
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/quasilyte/regex/syntax v0.0.0-20200805063351-8f842688393c/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -685,6 +760,8 @@ github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/rs/zerolog v1.23.0 h1:UskrK+saS9P9Y789yNNulYKdARjPZuS35B8gJF2x60g=
+github.com/rs/zerolog v1.23.0/go.mod h1:6c7hFfxPOy7TacJc4Fcdi24/J0NKYGzjG8FWRI916Qo=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.1.0/go.mod h1:4O8tr7hBODaGE6VIhfJDHcwzh5GUccKSJBU0UMXJFVM=
github.com/ryanrolds/sqlclosecheck v0.3.0/go.mod h1:1gREqxyTGR3lVtpngyFo3hZAgk0KCtEdgEkHwDbigdA=
@@ -693,6 +770,7 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0
github.com/sassoftware/go-rpmutils v0.0.0-20190420191620-a8f1baeba37b/go.mod h1:am+Fp8Bt506lA3Rk3QCmSqmYmLMnPDhdDUcosQCAx+I=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/securego/gosec/v2 v2.5.0/go.mod h1:L/CDXVntIff5ypVHIkqPXbtRpJiNCh6c6Amn68jXDjo=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
@@ -710,6 +788,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -728,6 +808,7 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
+github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
@@ -738,6 +819,7 @@ github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44=
@@ -749,6 +831,7 @@ github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5J
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
@@ -760,6 +843,7 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/tailscale/certstore v0.0.0-20210528134328-066c94b793d3/go.mod h1:2P+hpOwd53e7JMX/L4f3VXkv1G+33ES6IWZSrkIeWNs=
github.com/tailscale/depaware v0.0.0-20201214215404-77d1e9757027/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2 h1:reREUgl2FG+o7YCsrZB8XLjnuKv5hEIWtnOdAbRAXZI=
@@ -777,6 +861,7 @@ github.com/tomarrell/wrapcheck v0.0.0-20200807122107-df9e8bcb914d/go.mod h1:yiFB
github.com/tomarrell/wrapcheck v0.0.0-20201130113247-1683564d9756/go.mod h1:yiFB6fFoV7saXirUGfuK+cPtUh4NX/Hf5y2WC2lehu0=
github.com/tommy-muehle/go-mnd v1.3.1-0.20200224220436-e6f9a994e8fa/go.mod h1:dSUh0FtTP8VhvkL1S+gUR1OKd9ZnSaozuI6r3m6wOig=
github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
@@ -791,10 +876,22 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA=
github.com/valyala/quicktemplate v1.6.3/go.mod h1:fwPzK2fHuYEODzJ9pkw0ipCPNHZ2tD5KW4lOuSdPKzY=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
+github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
+github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
+github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
+github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
+github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -912,6 +1009,7 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -947,8 +1045,9 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -992,6 +1091,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1002,6 +1102,8 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191113165036-4c7a9d0fe056/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1022,7 +1124,9 @@ golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1048,14 +1152,19 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
+golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
+golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1092,6 +1201,7 @@ golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@@ -1330,6 +1440,8 @@ gorm.io/gorm v1.21.6/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.21.9/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.21.11 h1:CxkXW6Cc+VIBlL8yJEHq+Co4RYXdSLiMKNvgoZPjLK4=
gorm.io/gorm v1.21.11/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
+gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
+gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1361,5 +1473,3 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
tailscale.com v1.10.0 h1:3EWYxpXkCmXsMh1WgqoEjQ/xalxzxU+YD5ZmtaHS5cY=
tailscale.com v1.10.0/go.mod h1:kgFF5AZPTltwdXjX2/ci4ghlcO3qKNWVIjD9s39pr8c=
-tailscale.com v1.10.2 h1:0EbwydLGDxw7//yB5/1GTKz3hDJvGTUCajPZZPMDDGQ=
-tailscale.com v1.10.2/go.mod h1:kgFF5AZPTltwdXjX2/ci4ghlcO3qKNWVIjD9s39pr8c=
diff --git a/integration_test.go b/integration_test.go
new file mode 100644
index 00000000..8cdc1918
--- /dev/null
+++ b/integration_test.go
@@ -0,0 +1,401 @@
+// +build integration
+
+package headscale
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ory/dockertest/v3"
+ "github.com/ory/dockertest/v3/docker"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "inet.af/netaddr"
+)
+
+var integrationTmpDir string
+var ih Headscale
+
+var pool dockertest.Pool
+var network dockertest.Network
+var headscale dockertest.Resource
+var tailscaleCount int = 25
+var tailscales map[string]dockertest.Resource
+
+type IntegrationTestSuite struct {
+ suite.Suite
+ stats *suite.SuiteInformation
+}
+
+func TestIntegrationTestSuite(t *testing.T) {
+ s := new(IntegrationTestSuite)
+ suite.Run(t, s)
+
+ // HandleStats, which allows us to check if we passed and save logs
+ // is called after TearDown, so we cannot tear down containers before
+ // we have potentially saved the logs.
+ for _, tailscale := range tailscales {
+ if err := pool.Purge(&tailscale); err != nil {
+ log.Printf("Could not purge resource: %s\n", err)
+ }
+ }
+
+ if !s.stats.Passed() {
+ err := saveLog(&headscale, "test_output")
+ if err != nil {
+ log.Printf("Could not save log: %s\n", err)
+ }
+ }
+ if err := pool.Purge(&headscale); err != nil {
+ log.Printf("Could not purge resource: %s\n", err)
+ }
+
+ if err := network.Close(); err != nil {
+ log.Printf("Could not close network: %s\n", err)
+ }
+}
+
+func executeCommand(resource *dockertest.Resource, cmd []string) (string, error) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+
+ exitCode, err := resource.Exec(
+ cmd,
+ dockertest.ExecOptions{
+ StdOut: &stdout,
+ StdErr: &stderr,
+ },
+ )
+ if err != nil {
+ return "", err
+ }
+
+ if exitCode != 0 {
+ fmt.Println("Command: ", cmd)
+ fmt.Println("stdout: ", stdout.String())
+ fmt.Println("stderr: ", stderr.String())
+ return "", fmt.Errorf("command failed with: %s", stderr.String())
+ }
+
+ return stdout.String(), nil
+}
+
+func saveLog(resource *dockertest.Resource, basePath string) error {
+ err := os.MkdirAll(basePath, os.ModePerm)
+ if err != nil {
+ return err
+ }
+
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+
+ err = pool.Client.Logs(
+ docker.LogsOptions{
+ Context: context.TODO(),
+ Container: resource.Container.ID,
+ OutputStream: &stdout,
+ ErrorStream: &stderr,
+ Tail: "all",
+ RawTerminal: false,
+ Stdout: true,
+ Stderr: true,
+ Follow: false,
+ Timestamps: false,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("Saving logs for %s to %s\n", resource.Container.Name, basePath)
+
+ err = ioutil.WriteFile(path.Join(basePath, resource.Container.Name+".stdout.log"), []byte(stdout.String()), 0644)
+ if err != nil {
+ return err
+ }
+
+ err = ioutil.WriteFile(path.Join(basePath, resource.Container.Name+".stderr.log"), []byte(stdout.String()), 0644)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func dockerRestartPolicy(config *docker.HostConfig) {
+ // set AutoRemove to true so that stopped container goes away by itself
+ config.AutoRemove = true
+ config.RestartPolicy = docker.RestartPolicy{
+ Name: "no",
+ }
+}
+
+func (s *IntegrationTestSuite) SetupSuite() {
+ var err error
+ h = Headscale{
+ dbType: "sqlite3",
+ dbString: "integration_test_db.sqlite3",
+ }
+
+ if ppool, err := dockertest.NewPool(""); err == nil {
+ pool = *ppool
+ } else {
+ log.Fatalf("Could not connect to docker: %s", err)
+ }
+
+ if pnetwork, err := pool.CreateNetwork("headscale-test"); err == nil {
+ network = *pnetwork
+ } else {
+ log.Fatalf("Could not create network: %s", err)
+ }
+
+ headscaleBuildOptions := &dockertest.BuildOptions{
+ Dockerfile: "Dockerfile",
+ ContextDir: ".",
+ }
+
+ tailscaleBuildOptions := &dockertest.BuildOptions{
+ Dockerfile: "Dockerfile.tailscale",
+ ContextDir: ".",
+ }
+
+ currentPath, err := os.Getwd()
+ if err != nil {
+ log.Fatalf("Could not determine current path: %s", err)
+ }
+
+ headscaleOptions := &dockertest.RunOptions{
+ Name: "headscale",
+ Mounts: []string{
+ fmt.Sprintf("%s/integration_test/etc:/etc/headscale", currentPath),
+ fmt.Sprintf("%s/derp.yaml:/etc/headscale/derp.yaml", currentPath),
+ },
+ Networks: []*dockertest.Network{&network},
+ Cmd: []string{"headscale", "serve"},
+ PortBindings: map[docker.Port][]docker.PortBinding{
+ "8080/tcp": []docker.PortBinding{{HostPort: "8080"}},
+ },
+ }
+
+ fmt.Println("Creating headscale container")
+ if pheadscale, err := pool.BuildAndRunWithBuildOptions(headscaleBuildOptions, headscaleOptions, dockerRestartPolicy); err == nil {
+ headscale = *pheadscale
+ } else {
+ log.Fatalf("Could not start resource: %s", err)
+ }
+ fmt.Println("Created headscale container")
+
+ fmt.Println("Creating tailscale containers")
+ tailscales = make(map[string]dockertest.Resource)
+ for i := 0; i < tailscaleCount; i++ {
+ hostname := fmt.Sprintf("tailscale%d", i)
+ tailscaleOptions := &dockertest.RunOptions{
+ Name: hostname,
+ Networks: []*dockertest.Network{&network},
+ Cmd: []string{"tailscaled", "--tun=userspace-networking", "--socks5-server=localhost:1055"},
+ }
+
+ if pts, err := pool.BuildAndRunWithBuildOptions(tailscaleBuildOptions, tailscaleOptions, dockerRestartPolicy); err == nil {
+ tailscales[hostname] = *pts
+ } else {
+ log.Fatalf("Could not start resource: %s", err)
+ }
+ fmt.Printf("Created %s container\n", hostname)
+ }
+
+ fmt.Println("Waiting for headscale to be ready")
+ hostEndpoint := fmt.Sprintf("localhost:%s", headscale.GetPort("8080/tcp"))
+
+ if err := pool.Retry(func() error {
+ url := fmt.Sprintf("http://%s/health", hostEndpoint)
+ resp, err := http.Get(url)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("status code not OK")
+ }
+ return nil
+ }); err != nil {
+ log.Fatalf("Could not connect to docker: %s", err)
+ }
+ fmt.Println("headscale container is ready")
+
+ fmt.Println("Creating headscale namespace")
+ result, err := executeCommand(
+ &headscale,
+ []string{"headscale", "namespaces", "create", "test"},
+ )
+ assert.Nil(s.T(), err)
+
+ fmt.Println("Creating pre auth key")
+ authKey, err := executeCommand(
+ &headscale,
+ []string{"headscale", "-n", "test", "preauthkeys", "create", "--reusable", "--expiration", "24h"},
+ )
+ assert.Nil(s.T(), err)
+
+ headscaleEndpoint := fmt.Sprintf("http://headscale:%s", headscale.GetPort("8080/tcp"))
+
+ fmt.Printf("Joining tailscale containers to headscale at %s\n", headscaleEndpoint)
+ for hostname, tailscale := range tailscales {
+ command := []string{"tailscale", "up", "-login-server", headscaleEndpoint, "--authkey", strings.TrimSuffix(authKey, "\n"), "--hostname", hostname}
+
+ fmt.Println("Join command:", command)
+ fmt.Printf("Running join command for %s\n", hostname)
+ result, err = executeCommand(
+ &tailscale,
+ command,
+ )
+ fmt.Println("tailscale result: ", result)
+ assert.Nil(s.T(), err)
+ fmt.Printf("%s joined\n", hostname)
+ }
+
+ // The nodes need a bit of time to get their updated maps from headscale
+ // TODO: See if we can have a more deterministic wait here.
+ time.Sleep(60 * time.Second)
+}
+
+func (s *IntegrationTestSuite) TearDownSuite() {
+}
+
+func (s *IntegrationTestSuite) HandleStats(suiteName string, stats *suite.SuiteInformation) {
+ s.stats = stats
+}
+
+func (s *IntegrationTestSuite) TestListNodes() {
+ fmt.Println("Listing nodes")
+ result, err := executeCommand(
+ &headscale,
+ []string{"headscale", "-n", "test", "nodes", "list"},
+ )
+ assert.Nil(s.T(), err)
+
+ fmt.Printf("List nodes: \n%s\n", result)
+
+ // Chck that the correct count of host is present in node list
+ lines := strings.Split(result, "\n")
+ assert.Equal(s.T(), len(tailscales), len(lines)-2)
+
+ for hostname, _ := range tailscales {
+ assert.Contains(s.T(), result, hostname)
+ }
+}
+
+func (s *IntegrationTestSuite) TestGetIpAddresses() {
+ ipPrefix := netaddr.MustParseIPPrefix("100.64.0.0/10")
+ ips, err := getIPs()
+ assert.Nil(s.T(), err)
+
+ for hostname, _ := range tailscales {
+ s.T().Run(hostname, func(t *testing.T) {
+ ip := ips[hostname]
+
+ fmt.Printf("IP for %s: %s\n", hostname, ip)
+
+ // c.Assert(ip.Valid(), check.IsTrue)
+ assert.True(t, ip.Is4())
+ assert.True(t, ipPrefix.Contains(ip))
+
+ ips[hostname] = ip
+ })
+ }
+}
+
+func (s *IntegrationTestSuite) TestStatus() {
+ ips, err := getIPs()
+ assert.Nil(s.T(), err)
+
+ for hostname, tailscale := range tailscales {
+ s.T().Run(hostname, func(t *testing.T) {
+ command := []string{"tailscale", "status"}
+
+ fmt.Printf("Getting status for %s\n", hostname)
+ result, err := executeCommand(
+ &tailscale,
+ command,
+ )
+ assert.Nil(t, err)
+ // fmt.Printf("Status for %s: %s", hostname, result)
+
+ // Check if we have as many nodes in status
+ // as we have IPs/tailscales
+ lines := strings.Split(result, "\n")
+ assert.Equal(t, len(ips), len(lines)-1)
+ assert.Equal(t, len(tailscales), len(lines)-1)
+
+ // Check that all hosts is present in all hosts status
+ for ipHostname, ip := range ips {
+ assert.Contains(t, result, ip.String())
+ assert.Contains(t, result, ipHostname)
+ }
+ })
+ }
+}
+
+func (s *IntegrationTestSuite) TestPingAllPeers() {
+ ips, err := getIPs()
+ assert.Nil(s.T(), err)
+
+ for hostname, tailscale := range tailscales {
+ for peername, ip := range ips {
+ s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
+ // We currently cant ping ourselves, so skip that.
+ if peername != hostname {
+ // We are only interested in "direct ping" which means what we
+ // might need a couple of more attempts before reaching the node.
+ command := []string{
+ "tailscale", "ping",
+ "--timeout=1s",
+ "--c=20",
+ "--until-direct=true",
+ ip.String(),
+ }
+
+ fmt.Printf("Pinging from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip)
+ result, err := executeCommand(
+ &tailscale,
+ command,
+ )
+ assert.Nil(t, err)
+ fmt.Printf("Result for %s: %s\n", hostname, result)
+ assert.Contains(t, result, "pong")
+ }
+ })
+ }
+ }
+}
+
+func getIPs() (map[string]netaddr.IP, error) {
+ ips := make(map[string]netaddr.IP)
+ for hostname, tailscale := range tailscales {
+ command := []string{"tailscale", "ip"}
+
+ result, err := executeCommand(
+ &tailscale,
+ command,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ ip, err := netaddr.ParseIP(strings.TrimSuffix(result, "\n"))
+ if err != nil {
+ return nil, err
+ }
+
+ ips[hostname] = ip
+ }
+ return ips, nil
+}
diff --git a/integration_test/.gitignore b/integration_test/.gitignore
new file mode 100644
index 00000000..4e9cb7a1
--- /dev/null
+++ b/integration_test/.gitignore
@@ -0,0 +1,3 @@
+derp.yaml
+*.sqlite
+*.sqlite3
diff --git a/integration_test/etc/config.json b/integration_test/etc/config.json
new file mode 100644
index 00000000..8a6fd962
--- /dev/null
+++ b/integration_test/etc/config.json
@@ -0,0 +1,11 @@
+{
+ "server_url": "http://headscale:8080",
+ "listen_addr": "0.0.0.0:8080",
+ "private_key_path": "private.key",
+ "derp_map_path": "derp.yaml",
+ "ephemeral_node_inactivity_timeout": "30m",
+ "db_type": "sqlite3",
+ "db_path": "/tmp/integration_test_db.sqlite3",
+ "acl_policy_path": "",
+ "log_level": "debug"
+}
diff --git a/integration_test/etc/private.key b/integration_test/etc/private.key
new file mode 100644
index 00000000..b3a3ae6d
--- /dev/null
+++ b/integration_test/etc/private.key
@@ -0,0 +1 @@
+SEmQwCu+tGywQWEUsf93TpTRUvlB7WhnCdHgWrSXjEA=
diff --git a/k8s/README.md b/k8s/README.md
index 5cd18ce2..2f187abb 100644
--- a/k8s/README.md
+++ b/k8s/README.md
@@ -65,7 +65,6 @@ tasks like creating namespaces, authkeys, etc.
headscale is an open source implementation of the Tailscale control server
-Juan Font Alonso - 2021
https://gitlab.com/juanfont/headscale
Usage:
@@ -90,7 +89,6 @@ Use "headscale [command] --help" for more information about a command.
# TODO / Ideas
-- Github action to publish the docker image
- Interpolate `email:` option to the ClusterIssuer from site configuration.
This probably needs to be done with a transformer, kustomize vars don't seem to work.
- Add kustomize examples for cloud-native ingress, load balancer
diff --git a/machine.go b/machine.go
index 6f88e8d5..3e9786a2 100644
--- a/machine.go
+++ b/machine.go
@@ -2,12 +2,14 @@ package headscale
import (
"encoding/json"
+ "errors"
"fmt"
- "log"
"sort"
"strconv"
"time"
+ "github.com/rs/zerolog/log"
+
"gorm.io/datatypes"
"inet.af/netaddr"
"tailscale.com/tailcfg"
@@ -30,8 +32,9 @@ type Machine struct {
AuthKeyID uint
AuthKey *PreAuthKey
- LastSeen *time.Time
- Expiry *time.Time
+ LastSeen *time.Time
+ LastSuccessfulUpdate *time.Time
+ Expiry *time.Time
HostInfo datatypes.JSON
Endpoints datatypes.JSON
@@ -47,7 +50,9 @@ func (m Machine) isAlreadyRegistered() bool {
return m.Registered
}
-func (m Machine) toNode() (*tailcfg.Node, error) {
+// toNode converts a Machine into a Tailscale Node. includeRoutes is false for shared nodes
+// as per the expected behaviour in the official SaaS
+func (m Machine) toNode(includeRoutes bool) (*tailcfg.Node, error) {
nKey, err := wgkey.ParseHex(m.NodeKey)
if err != nil {
return nil, err
@@ -71,6 +76,10 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
addrs := []netaddr.IPPrefix{}
ip, err := netaddr.ParseIPPrefix(fmt.Sprintf("%s/32", m.IPAddress))
if err != nil {
+ log.Trace().
+ Str("func", "toNode").
+ Str("ip", m.IPAddress).
+ Msgf("Failed to parse IP Prefix from IP: %s", m.IPAddress)
return nil, err
}
addrs = append(addrs, ip) // missing the ipv6 ?
@@ -78,24 +87,26 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
allowedIPs := []netaddr.IPPrefix{}
allowedIPs = append(allowedIPs, ip) // we append the node own IP, as it is required by the clients
- routesStr := []string{}
- if len(m.EnabledRoutes) != 0 {
- allwIps, err := m.EnabledRoutes.MarshalJSON()
- if err != nil {
- return nil, err
+ if includeRoutes {
+ routesStr := []string{}
+ if len(m.EnabledRoutes) != 0 {
+ allwIps, err := m.EnabledRoutes.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+ err = json.Unmarshal(allwIps, &routesStr)
+ if err != nil {
+ return nil, err
+ }
}
- err = json.Unmarshal(allwIps, &routesStr)
- if err != nil {
- return nil, err
- }
- }
- for _, aip := range routesStr {
- ip, err := netaddr.ParseIPPrefix(aip)
- if err != nil {
- return nil, err
+ for _, routeStr := range routesStr {
+ ip, err := netaddr.ParseIPPrefix(routeStr)
+ if err != nil {
+ return nil, err
+ }
+ allowedIPs = append(allowedIPs, ip)
}
- allowedIPs = append(allowedIPs, ip)
}
endpoints := []string{}
@@ -129,13 +140,20 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
derp = "127.3.3.40:0" // Zero means disconnected or unknown.
}
+ var keyExpiry time.Time
+ if m.Expiry != nil {
+ keyExpiry = *m.Expiry
+ } else {
+ keyExpiry = time.Time{}
+ }
+
n := tailcfg.Node{
ID: tailcfg.NodeID(m.ID), // this is the actual ID
StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)), // in headscale, unlike tailcontrol server, IDs are permanent
Name: hostinfo.Hostname,
User: tailcfg.UserID(m.NamespaceID),
Key: tailcfg.NodeKey(nKey),
- KeyExpiry: *m.Expiry,
+ KeyExpiry: keyExpiry,
Machine: tailcfg.MachineKey(mKey),
DiscoKey: discoKey,
Addresses: addrs,
@@ -154,22 +172,46 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
}
func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) {
+ log.Trace().
+ Str("func", "getPeers").
+ Str("machine", m.Name).
+ Msg("Finding peers")
+
machines := []Machine{}
if err := h.db.Where("namespace_id = ? AND machine_key <> ? AND registered",
m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil {
- log.Printf("Error accessing db: %s", err)
+ log.Error().Err(err).Msg("Error accessing db")
+ return nil, err
+ }
+
+ // We fetch here machines that are shared to the `Namespace` of the machine we are getting peers for
+ sharedMachines := []SharedMachine{}
+ if err := h.db.Preload("Namespace").Preload("Machine").Where("namespace_id = ?",
+ m.NamespaceID).Find(&sharedMachines).Error; err != nil {
return nil, err
}
peers := []*tailcfg.Node{}
for _, mn := range machines {
- peer, err := mn.toNode()
+ peer, err := mn.toNode(true)
+ if err != nil {
+ return nil, err
+ }
+ peers = append(peers, peer)
+ }
+ for _, sharedMachine := range sharedMachines {
+ peer, err := sharedMachine.Machine.toNode(false) // shared nodes do not expose their routes
if err != nil {
return nil, err
}
peers = append(peers, peer)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].ID < peers[j].ID })
+
+ log.Trace().
+ Str("func", "getPeers").
+ Str("machine", m.Name).
+ Msgf("Found peers: %s", tailNodesToString(peers))
return &peers, nil
}
@@ -185,34 +227,46 @@ func (h *Headscale) GetMachine(namespace string, name string) (*Machine, error)
return &m, nil
}
}
- return nil, fmt.Errorf("not found")
+ return nil, fmt.Errorf("machine not found")
}
// GetMachineByID finds a Machine by ID and returns the Machine struct
func (h *Headscale) GetMachineByID(id uint64) (*Machine, error) {
m := Machine{}
- if result := h.db.Find(&Machine{ID: id}).First(&m); result.Error != nil {
+ if result := h.db.Preload("Namespace").Find(&Machine{ID: id}).First(&m); result.Error != nil {
return nil, result.Error
}
return &m, nil
}
-// DeleteMachine softs deletes a Machine from the database
-func (h *Headscale) DeleteMachine(m *Machine) error {
- m.Registered = false
- h.db.Save(&m) // we mark it as unregistered, just in case
- if err := h.db.Delete(&m).Error; err != nil {
- return err
+// UpdateMachine takes a Machine struct pointer (typically already loaded from database
+// and updates it with the latest data from the database.
+func (h *Headscale) UpdateMachine(m *Machine) error {
+ if result := h.db.Find(m).First(&m); result.Error != nil {
+ return result.Error
}
return nil
}
+// DeleteMachine softs deletes a Machine from the database
+func (h *Headscale) DeleteMachine(m *Machine) error {
+ m.Registered = false
+ namespaceID := m.NamespaceID
+ h.db.Save(&m) // we mark it as unregistered, just in case
+ if err := h.db.Delete(&m).Error; err != nil {
+ return err
+ }
+
+ return h.RequestMapUpdates(namespaceID)
+}
+
// HardDeleteMachine hard deletes a Machine from the database
func (h *Headscale) HardDeleteMachine(m *Machine) error {
+ namespaceID := m.NamespaceID
if err := h.db.Unscoped().Delete(&m).Error; err != nil {
return err
}
- return nil
+ return h.RequestMapUpdates(namespaceID)
}
// GetHostInfo returns a Hostinfo struct for the machine
@@ -230,3 +284,121 @@ func (m *Machine) GetHostInfo() (*tailcfg.Hostinfo, error) {
}
return &hostinfo, nil
}
+
+func (h *Headscale) notifyChangesToPeers(m *Machine) {
+ peers, err := h.getPeers(*m)
+ if err != nil {
+ log.Error().
+ Str("func", "notifyChangesToPeers").
+ Str("machine", m.Name).
+ Msgf("Error getting peers: %s", err)
+ return
+ }
+ for _, p := range *peers {
+ log.Info().
+ Str("func", "notifyChangesToPeers").
+ Str("machine", m.Name).
+ Str("peer", p.Name).
+ Str("address", p.Addresses[0].String()).
+ Msgf("Notifying peer %s (%s)", p.Name, p.Addresses[0])
+ err := h.sendRequestOnUpdateChannel(p)
+ if err != nil {
+ log.Info().
+ Str("func", "notifyChangesToPeers").
+ Str("machine", m.Name).
+ Str("peer", p.Name).
+ Msgf("Peer %s does not appear to be polling", p.Name)
+ }
+ log.Trace().
+ Str("func", "notifyChangesToPeers").
+ Str("machine", m.Name).
+ Str("peer", p.Name).
+ Str("address", p.Addresses[0].String()).
+ Msgf("Notified peer %s (%s)", p.Name, p.Addresses[0])
+ }
+}
+
+func (h *Headscale) getOrOpenUpdateChannel(m *Machine) <-chan struct{} {
+ var updateChan chan struct{}
+ if storedChan, ok := h.clientsUpdateChannels.Load(m.ID); ok {
+ if unwrapped, ok := storedChan.(chan struct{}); ok {
+ updateChan = unwrapped
+ } else {
+ log.Error().
+ Str("handler", "openUpdateChannel").
+ Str("machine", m.Name).
+ Msg("Failed to convert update channel to struct{}")
+ }
+ } else {
+ log.Debug().
+ Str("handler", "openUpdateChannel").
+ Str("machine", m.Name).
+ Msg("Update channel not found, creating")
+
+ updateChan = make(chan struct{})
+ h.clientsUpdateChannels.Store(m.ID, updateChan)
+ }
+ return updateChan
+}
+
+func (h *Headscale) closeUpdateChannel(m *Machine) {
+ h.clientsUpdateChannelMutex.Lock()
+ defer h.clientsUpdateChannelMutex.Unlock()
+
+ if storedChan, ok := h.clientsUpdateChannels.Load(m.ID); ok {
+ if unwrapped, ok := storedChan.(chan struct{}); ok {
+ close(unwrapped)
+ }
+ }
+ h.clientsUpdateChannels.Delete(m.ID)
+}
+
+func (h *Headscale) sendRequestOnUpdateChannel(m *tailcfg.Node) error {
+ h.clientsUpdateChannelMutex.Lock()
+ defer h.clientsUpdateChannelMutex.Unlock()
+
+ pUp, ok := h.clientsUpdateChannels.Load(uint64(m.ID))
+ if ok {
+ log.Info().
+ Str("func", "requestUpdate").
+ Str("machine", m.Name).
+ Msgf("Notifying peer %s", m.Name)
+
+ if update, ok := pUp.(chan struct{}); ok {
+ log.Trace().
+ Str("func", "requestUpdate").
+ Str("machine", m.Name).
+ Msgf("Update channel is %#v", update)
+
+ update <- struct{}{}
+
+ log.Trace().
+ Str("func", "requestUpdate").
+ Str("machine", m.Name).
+ Msgf("Notified machine %s", m.Name)
+ }
+ } else {
+ log.Info().
+ Str("func", "requestUpdate").
+ Str("machine", m.Name).
+ Msgf("Machine %s does not appear to be polling", m.Name)
+ return errors.New("machine does not seem to be polling")
+ }
+ return nil
+}
+
+func (h *Headscale) isOutdated(m *Machine) bool {
+ err := h.UpdateMachine(m)
+ if err != nil {
+ return true
+ }
+
+ lastChange := h.getLastStateChange(m.Namespace.Name)
+ log.Trace().
+ Str("func", "keepAlive").
+ Str("machine", m.Name).
+ Time("last_successful_update", *m.LastSuccessfulUpdate).
+ Time("last_state_change", lastChange).
+ Msgf("Checking if %s is missing updates", m.Name)
+ return m.LastSuccessfulUpdate.Before(lastChange)
+}
diff --git a/machine_test.go b/machine_test.go
index 1bd29a9d..d535be56 100644
--- a/machine_test.go
+++ b/machine_test.go
@@ -1,6 +1,8 @@
package headscale
import (
+ "encoding/json"
+
"gopkg.in/check.v1"
)
@@ -81,6 +83,15 @@ func (s *Suite) TestDeleteMachine(c *check.C) {
h.db.Save(&m)
err = h.DeleteMachine(&m)
c.Assert(err, check.IsNil)
+ v, err := h.getValue("namespaces_pending_updates")
+ c.Assert(err, check.IsNil)
+ names := []string{}
+ err = json.Unmarshal([]byte(v), &names)
+ c.Assert(err, check.IsNil)
+ c.Assert(names, check.DeepEquals, []string{n.Name})
+ h.checkForNamespacesPendingUpdates()
+ v, _ = h.getValue("namespaces_pending_updates")
+ c.Assert(v, check.Equals, "")
_, err = h.GetMachine(n.Name, "testmachine")
c.Assert(err, check.NotNil)
}
diff --git a/namespaces.go b/namespaces.go
index 9897640c..8204f96d 100644
--- a/namespaces.go
+++ b/namespaces.go
@@ -1,10 +1,12 @@
package headscale
import (
+ "encoding/json"
"errors"
- "log"
+ "fmt"
"time"
+ "github.com/rs/zerolog/log"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -31,7 +33,10 @@ func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
}
n.Name = name
if err := h.db.Create(&n).Error; err != nil {
- log.Printf("Could not create row: %s", err)
+ log.Error().
+ Str("func", "CreateNamespace").
+ Err(err).
+ Msg("Could not create row")
return nil, err
}
return &n, nil
@@ -86,12 +91,34 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
}
machines := []Machine{}
- if err := h.db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
+ if err := h.db.Preload("AuthKey").Preload("Namespace").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
return nil, err
}
return &machines, nil
}
+// ListSharedMachinesInNamespace returns all the machines that are shared to the specified namespace
+func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, error) {
+ namespace, err := h.GetNamespace(name)
+ if err != nil {
+ return nil, err
+ }
+ sharedMachines := []SharedMachine{}
+ if err := h.db.Preload("Namespace").Where(&SharedMachine{NamespaceID: namespace.ID}).Find(&sharedMachines).Error; err != nil {
+ return nil, err
+ }
+
+ machines := []Machine{}
+ for _, sharedMachine := range sharedMachines {
+ machine, err := h.GetMachineByID(sharedMachine.MachineID) // otherwise not everything comes filled
+ if err != nil {
+ return nil, err
+ }
+ machines = append(machines, *machine)
+ }
+ return &machines, nil
+}
+
// SetMachineNamespace assigns a Machine to a namespace
func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error {
n, err := h.GetNamespace(namespaceName)
@@ -103,6 +130,86 @@ func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error
return nil
}
+// RequestMapUpdates signals the KV worker to update the maps for this namespace
+func (h *Headscale) RequestMapUpdates(namespaceID uint) error {
+ namespace := Namespace{}
+ if err := h.db.First(&namespace, namespaceID).Error; err != nil {
+ return err
+ }
+
+ v, err := h.getValue("namespaces_pending_updates")
+ if err != nil || v == "" {
+ err = h.setValue("namespaces_pending_updates", fmt.Sprintf(`["%s"]`, namespace.Name))
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+ names := []string{}
+ err = json.Unmarshal([]byte(v), &names)
+ if err != nil {
+ err = h.setValue("namespaces_pending_updates", fmt.Sprintf(`["%s"]`, namespace.Name))
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+
+ names = append(names, namespace.Name)
+ data, err := json.Marshal(names)
+ if err != nil {
+ log.Error().
+ Str("func", "RequestMapUpdates").
+ Err(err).
+ Msg("Could not marshal namespaces_pending_updates")
+ return err
+ }
+ return h.setValue("namespaces_pending_updates", string(data))
+}
+
+func (h *Headscale) checkForNamespacesPendingUpdates() {
+ v, err := h.getValue("namespaces_pending_updates")
+ if err != nil {
+ return
+ }
+ if v == "" {
+ return
+ }
+
+ names := []string{}
+ err = json.Unmarshal([]byte(v), &names)
+ if err != nil {
+ return
+ }
+ for _, name := range names {
+ log.Trace().
+ Str("func", "RequestMapUpdates").
+ Str("machine", name).
+ Msg("Sending updates to nodes in namespace")
+ machines, err := h.ListMachinesInNamespace(name)
+ if err != nil {
+ continue
+ }
+ for _, m := range *machines {
+ h.notifyChangesToPeers(&m)
+ }
+ }
+ newV, err := h.getValue("namespaces_pending_updates")
+ if err != nil {
+ return
+ }
+ if v == newV { // only clear when no changes, so we notified everybody
+ err = h.setValue("namespaces_pending_updates", "")
+ if err != nil {
+ log.Error().
+ Str("func", "checkForNamespacesPendingUpdates").
+ Err(err).
+ Msg("Could not save to KV")
+ return
+ }
+ }
+}
+
func (n *Namespace) toUser() *tailcfg.User {
u := tailcfg.User{
ID: tailcfg.UserID(n.ID),
diff --git a/poll.go b/poll.go
new file mode 100644
index 00000000..60bfa9ea
--- /dev/null
+++ b/poll.go
@@ -0,0 +1,454 @@
+package headscale
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/rs/zerolog/log"
+ "gorm.io/datatypes"
+ "gorm.io/gorm"
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/wgkey"
+)
+
+// PollNetMapHandler takes care of /machine/:id/map
+//
+// This is the busiest endpoint, as it keeps the HTTP long poll that updates
+// the clients when something in the network changes.
+//
+// The clients POST stuff like HostInfo and their Endpoints here, but
+// only after their first request (marked with the ReadOnly field).
+//
+// At this moment the updates are sent in a quite horrendous way, but they kinda work.
+func (h *Headscale) PollNetMapHandler(c *gin.Context) {
+ log.Trace().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Msg("PollNetMapHandler called")
+ body, _ := io.ReadAll(c.Request.Body)
+ mKeyStr := c.Param("id")
+ mKey, err := wgkey.ParseHex(mKeyStr)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMap").
+ Err(err).
+ Msg("Cannot parse client key")
+ c.String(http.StatusBadRequest, "")
+ return
+ }
+ req := tailcfg.MapRequest{}
+ err = decode(body, &req, &mKey, h.privateKey)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMap").
+ Err(err).
+ Msg("Cannot decode message")
+ c.String(http.StatusBadRequest, "")
+ return
+ }
+
+ var m Machine
+ if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ log.Warn().
+ Str("handler", "PollNetMap").
+ Msgf("Ignoring request, cannot find machine with key %s", mKey.HexString())
+ c.String(http.StatusUnauthorized, "")
+ return
+ }
+ log.Trace().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Str("machine", m.Name).
+ Msg("Found machine in database")
+
+ hostinfo, _ := json.Marshal(req.Hostinfo)
+ m.Name = req.Hostinfo.Hostname
+ m.HostInfo = datatypes.JSON(hostinfo)
+ m.DiscoKey = wgkey.Key(req.DiscoKey).HexString()
+ now := time.Now().UTC()
+
+ // From Tailscale client:
+ //
+ // ReadOnly is whether the client just wants to fetch the MapResponse,
+ // without updating their Endpoints. The Endpoints field will be ignored and
+ // LastSeen will not be updated and peers will not be notified of changes.
+ //
+ // The intended use is for clients to discover the DERP map at start-up
+ // before their first real endpoint update.
+ if !req.ReadOnly {
+ endpoints, _ := json.Marshal(req.Endpoints)
+ m.Endpoints = datatypes.JSON(endpoints)
+ m.LastSeen = &now
+ }
+ h.db.Save(&m)
+
+ data, err := h.getMapResponse(mKey, req, m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Str("machine", m.Name).
+ Err(err).
+ Msg("Failed to get Map response")
+ c.String(http.StatusInternalServerError, ":(")
+ return
+ }
+
+ // We update our peers if the client is not sending ReadOnly in the MapRequest
+ // so we don't distribute its initial request (it comes with
+ // empty endpoints to peers)
+
+ // Details on the protocol can be found in https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L696
+ log.Debug().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Str("machine", m.Name).
+ Bool("readOnly", req.ReadOnly).
+ Bool("omitPeers", req.OmitPeers).
+ Bool("stream", req.Stream).
+ Msg("Client map request processed")
+
+ if req.ReadOnly {
+ log.Info().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Client is starting up. Probably interested in a DERP map")
+ c.Data(200, "application/json; charset=utf-8", *data)
+ return
+ }
+
+ // There has been an update to _any_ of the nodes that the other nodes would
+ // need to know about
+ h.setLastStateChangeToNow(m.Namespace.Name)
+
+ // The request is not ReadOnly, so we need to set up channels for updating
+ // peers via longpoll
+
+ // Only create update channel if it has not been created
+ log.Trace().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Str("machine", m.Name).
+ Msg("Loading or creating update channel")
+ updateChan := h.getOrOpenUpdateChannel(&m)
+
+ pollDataChan := make(chan []byte)
+ // defer close(pollData)
+
+ keepAliveChan := make(chan []byte)
+
+ cancelKeepAlive := make(chan struct{})
+ defer close(cancelKeepAlive)
+
+ if req.OmitPeers && !req.Stream {
+ log.Info().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Client sent endpoint update and is ok with a response without peer list")
+ c.Data(200, "application/json; charset=utf-8", *data)
+
+ // It sounds like we should update the nodes when we have received a endpoint update
+ // even tho the comments in the tailscale code dont explicitly say so.
+ go h.notifyChangesToPeers(&m)
+ return
+ } else if req.OmitPeers && req.Stream {
+ log.Warn().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Ignoring request, don't know how to handle it")
+ c.String(http.StatusBadRequest, "")
+ return
+ }
+
+ log.Info().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Client is ready to access the tailnet")
+ log.Info().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Sending initial map")
+ go func() { pollDataChan <- *data }()
+
+ log.Info().
+ Str("handler", "PollNetMap").
+ Str("machine", m.Name).
+ Msg("Notifying peers")
+ go h.notifyChangesToPeers(&m)
+
+ h.PollNetMapStream(c, m, req, mKey, pollDataChan, keepAliveChan, updateChan, cancelKeepAlive)
+ log.Trace().
+ Str("handler", "PollNetMap").
+ Str("id", c.Param("id")).
+ Str("machine", m.Name).
+ Msg("Finished stream, closing PollNetMap session")
+}
+
+// PollNetMapStream takes care of /machine/:id/map
+// stream logic, ensuring we communicate updates and data
+// to the connected clients.
+func (h *Headscale) PollNetMapStream(
+ c *gin.Context,
+ m Machine,
+ req tailcfg.MapRequest,
+ mKey wgkey.Key,
+ pollDataChan chan []byte,
+ keepAliveChan chan []byte,
+ updateChan <-chan struct{},
+ cancelKeepAlive chan struct{},
+) {
+ go h.scheduledPollWorker(cancelKeepAlive, keepAliveChan, mKey, req, m)
+
+ c.Stream(func(w io.Writer) bool {
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Msg("Waiting for data to stream...")
+
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Msgf("pollData is %#v, keepAliveChan is %#v, updateChan is %#v", pollDataChan, keepAliveChan, updateChan)
+
+ select {
+ case data := <-pollDataChan:
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "pollData").
+ Int("bytes", len(data)).
+ Msg("Sending data received via pollData channel")
+ _, err := w.Write(data)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "pollData").
+ Err(err).
+ Msg("Cannot write data")
+ }
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "pollData").
+ Int("bytes", len(data)).
+ Msg("Data from pollData channel written successfully")
+ // TODO: Abstract away all the database calls, this can cause race conditions
+ // when an outdated machine object is kept alive, e.g. db is update from
+ // command line, but then overwritten.
+ err = h.UpdateMachine(&m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "pollData").
+ Err(err).
+ Msg("Cannot update machine from database")
+ }
+ now := time.Now().UTC()
+ m.LastSeen = &now
+ m.LastSuccessfulUpdate = &now
+ h.db.Save(&m)
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "pollData").
+ Int("bytes", len(data)).
+ Msg("Machine updated successfully after sending pollData")
+ return true
+
+ case data := <-keepAliveChan:
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "keepAlive").
+ Int("bytes", len(data)).
+ Msg("Sending keep alive message")
+ _, err := w.Write(data)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "keepAlive").
+ Err(err).
+ Msg("Cannot write keep alive message")
+ }
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "keepAlive").
+ Int("bytes", len(data)).
+ Msg("Keep alive sent successfully")
+ // TODO: Abstract away all the database calls, this can cause race conditions
+ // when an outdated machine object is kept alive, e.g. db is update from
+ // command line, but then overwritten.
+ err = h.UpdateMachine(&m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "keepAlive").
+ Err(err).
+ Msg("Cannot update machine from database")
+ }
+ now := time.Now().UTC()
+ m.LastSeen = &now
+ h.db.Save(&m)
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "keepAlive").
+ Int("bytes", len(data)).
+ Msg("Machine updated successfully after sending keep alive")
+ return true
+
+ case <-updateChan:
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "update").
+ Msg("Received a request for update")
+ if h.isOutdated(&m) {
+ log.Debug().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Time("last_successful_update", *m.LastSuccessfulUpdate).
+ Time("last_state_change", h.getLastStateChange(m.Namespace.Name)).
+ Msgf("There has been updates since the last successful update to %s", m.Name)
+ data, err := h.getMapResponse(mKey, req, m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "update").
+ Err(err).
+ Msg("Could not get the map update")
+ }
+ _, err = w.Write(*data)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "update").
+ Err(err).
+ Msg("Could not write the map response")
+ }
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "update").
+ Msg("Updated Map has been sent")
+
+ // Keep track of the last successful update,
+ // we sometimes end in a state were the update
+ // is not picked up by a client and we use this
+ // to determine if we should "force" an update.
+ // TODO: Abstract away all the database calls, this can cause race conditions
+ // when an outdated machine object is kept alive, e.g. db is update from
+ // command line, but then overwritten.
+ err = h.UpdateMachine(&m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "update").
+ Err(err).
+ Msg("Cannot update machine from database")
+ }
+ now := time.Now().UTC()
+ m.LastSuccessfulUpdate = &now
+ h.db.Save(&m)
+ } else {
+ log.Trace().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Time("last_successful_update", *m.LastSuccessfulUpdate).
+ Time("last_state_change", h.getLastStateChange(m.Namespace.Name)).
+ Msgf("%s is up to date", m.Name)
+ }
+ return true
+
+ case <-c.Request.Context().Done():
+ log.Info().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Msg("The client has closed the connection")
+ // TODO: Abstract away all the database calls, this can cause race conditions
+ // when an outdated machine object is kept alive, e.g. db is update from
+ // command line, but then overwritten.
+ err := h.UpdateMachine(&m)
+ if err != nil {
+ log.Error().
+ Str("handler", "PollNetMapStream").
+ Str("machine", m.Name).
+ Str("channel", "Done").
+ Err(err).
+ Msg("Cannot update machine from database")
+ }
+ now := time.Now().UTC()
+ m.LastSeen = &now
+ h.db.Save(&m)
+
+ cancelKeepAlive <- struct{}{}
+
+ h.closeUpdateChannel(&m)
+
+ close(pollDataChan)
+
+ close(keepAliveChan)
+
+ return false
+ }
+ })
+}
+
+func (h *Headscale) scheduledPollWorker(
+ cancelChan <-chan struct{},
+ keepAliveChan chan<- []byte,
+ mKey wgkey.Key,
+ req tailcfg.MapRequest,
+ m Machine,
+) {
+ keepAliveTicker := time.NewTicker(60 * time.Second)
+ updateCheckerTicker := time.NewTicker(30 * time.Second)
+
+ for {
+ select {
+ case <-cancelChan:
+ return
+
+ case <-keepAliveTicker.C:
+ data, err := h.getMapKeepAliveResponse(mKey, req, m)
+ if err != nil {
+ log.Error().
+ Str("func", "keepAlive").
+ Err(err).
+ Msg("Error generating the keep alive msg")
+ return
+ }
+
+ log.Debug().
+ Str("func", "keepAlive").
+ Str("machine", m.Name).
+ Msg("Sending keepalive")
+ keepAliveChan <- *data
+
+ case <-updateCheckerTicker.C:
+ // Send an update request regardless of outdated or not, if data is sent
+ // to the node is determined in the updateChan consumer block
+ n, _ := m.toNode(true)
+ err := h.sendRequestOnUpdateChannel(n)
+ if err != nil {
+ log.Error().
+ Str("func", "keepAlive").
+ Str("machine", m.Name).
+ Err(err).
+ Msgf("Failed to send update request to %s", m.Name)
+ }
+ }
+ }
+}
diff --git a/preauth_keys.go b/preauth_keys.go
index 7cffceae..cc849fc0 100644
--- a/preauth_keys.go
+++ b/preauth_keys.go
@@ -67,6 +67,28 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
return &keys, nil
}
+// GetPreAuthKey returns a PreAuthKey for a given key
+func (h *Headscale) GetPreAuthKey(namespace string, key string) (*PreAuthKey, error) {
+ pak, err := h.checkKeyValidity(key)
+ if err != nil {
+ return nil, err
+ }
+
+ if pak.Namespace.Name != namespace {
+ return nil, errors.New("Namespace mismatch")
+ }
+
+ return pak, nil
+}
+
+// MarkExpirePreAuthKey marks a PreAuthKey as expired
+func (h *Headscale) MarkExpirePreAuthKey(k *PreAuthKey) error {
+ if err := h.db.Model(&k).Update("Expiration", time.Now()).Error; err != nil {
+ return err
+ }
+ return nil
+}
+
// checkKeyValidity does the heavy lifting for validation of the PreAuthKey coming from a node
// If returns no error and a PreAuthKey, it can be used
func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
diff --git a/preauth_keys_test.go b/preauth_keys_test.go
index 6f1369c5..37f2e4dd 100644
--- a/preauth_keys_test.go
+++ b/preauth_keys_test.go
@@ -163,3 +163,20 @@ func (*Suite) TestEphemeralKey(c *check.C) {
_, err = h.GetMachine("test7", "testest")
c.Assert(err, check.NotNil)
}
+
+func (*Suite) TestExpirePreauthKey(c *check.C) {
+ n, err := h.CreateNamespace("test3")
+ c.Assert(err, check.IsNil)
+
+ pak, err := h.CreatePreAuthKey(n.Name, true, false, nil)
+ c.Assert(err, check.IsNil)
+ c.Assert(pak.Expiration, check.IsNil)
+
+ err = h.MarkExpirePreAuthKey(pak)
+ c.Assert(err, check.IsNil)
+ c.Assert(pak.Expiration, check.NotNil)
+
+ p, err := h.checkKeyValidity(pak.Key)
+ c.Assert(err, check.Equals, errorAuthKeyExpired)
+ c.Assert(p, check.IsNil)
+}
diff --git a/routes.go b/routes.go
index a02bed30..0ef01780 100644
--- a/routes.go
+++ b/routes.go
@@ -2,64 +2,142 @@ package headscale
import (
"encoding/json"
- "errors"
+ "fmt"
+ "strconv"
+ "github.com/pterm/pterm"
"gorm.io/datatypes"
"inet.af/netaddr"
)
-// GetNodeRoutes returns the subnet routes advertised by a node (identified by
+// GetAdvertisedNodeRoutes returns the subnet routes advertised by a node (identified by
// namespace and node name)
-func (h *Headscale) GetNodeRoutes(namespace string, nodeName string) (*[]netaddr.IPPrefix, error) {
+func (h *Headscale) GetAdvertisedNodeRoutes(namespace string, nodeName string) (*[]netaddr.IPPrefix, error) {
m, err := h.GetMachine(namespace, nodeName)
if err != nil {
return nil, err
}
- hi, err := m.GetHostInfo()
+ hostInfo, err := m.GetHostInfo()
if err != nil {
return nil, err
}
- return &hi.RoutableIPs, nil
+ return &hostInfo.RoutableIPs, nil
+}
+
+// GetEnabledNodeRoutes returns the subnet routes enabled by a node (identified by
+// namespace and node name)
+func (h *Headscale) GetEnabledNodeRoutes(namespace string, nodeName string) ([]netaddr.IPPrefix, error) {
+ m, err := h.GetMachine(namespace, nodeName)
+ if err != nil {
+ return nil, err
+ }
+
+ data, err := m.EnabledRoutes.MarshalJSON()
+ if err != nil {
+ return nil, err
+ }
+
+ routesStr := []string{}
+ err = json.Unmarshal(data, &routesStr)
+ if err != nil {
+ return nil, err
+ }
+
+ routes := make([]netaddr.IPPrefix, len(routesStr))
+ for index, routeStr := range routesStr {
+ route, err := netaddr.ParseIPPrefix(routeStr)
+ if err != nil {
+ return nil, err
+ }
+ routes[index] = route
+ }
+
+ return routes, nil
+}
+
+// IsNodeRouteEnabled checks if a certain route has been enabled
+func (h *Headscale) IsNodeRouteEnabled(namespace string, nodeName string, routeStr string) bool {
+ route, err := netaddr.ParseIPPrefix(routeStr)
+ if err != nil {
+ return false
+ }
+
+ enabledRoutes, err := h.GetEnabledNodeRoutes(namespace, nodeName)
+ if err != nil {
+ return false
+ }
+
+ for _, enabledRoute := range enabledRoutes {
+ if route == enabledRoute {
+ return true
+ }
+ }
+ return false
}
// EnableNodeRoute enables a subnet route advertised by a node (identified by
// namespace and node name)
-func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr string) (*netaddr.IPPrefix, error) {
+func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr string) error {
m, err := h.GetMachine(namespace, nodeName)
if err != nil {
- return nil, err
- }
- hi, err := m.GetHostInfo()
- if err != nil {
- return nil, err
+ return err
}
+
route, err := netaddr.ParseIPPrefix(routeStr)
if err != nil {
- return nil, err
+ return err
}
- for _, rIP := range hi.RoutableIPs {
- if rIP == route {
- routes, _ := json.Marshal([]string{routeStr}) // TODO: only one for the time being, so overwriting the rest
- m.EnabledRoutes = datatypes.JSON(routes)
- h.db.Save(&m)
+ availableRoutes, err := h.GetAdvertisedNodeRoutes(namespace, nodeName)
+ if err != nil {
+ return err
+ }
- // THIS IS COMPLETELY USELESS.
- // The peers map is stored in memory in the server process.
- // Definetely not accessible from the CLI tool.
- // We need RPC to the server - or some kind of 'needsUpdate' field in the DB
- peers, _ := h.getPeers(*m)
- h.pollMu.Lock()
- for _, p := range *peers {
- if pUp, ok := h.clientsPolling[uint64(p.ID)]; ok {
- pUp <- []byte{}
- }
+ enabledRoutes, err := h.GetEnabledNodeRoutes(namespace, nodeName)
+ if err != nil {
+ return err
+ }
+
+ available := false
+ for _, availableRoute := range *availableRoutes {
+ // If the route is available, and not yet enabled, add it to the new routing table
+ if route == availableRoute {
+ available = true
+ if !h.IsNodeRouteEnabled(namespace, nodeName, routeStr) {
+ enabledRoutes = append(enabledRoutes, route)
}
- h.pollMu.Unlock()
- return &rIP, nil
}
}
- return nil, errors.New("could not find routable range")
+ if !available {
+ return fmt.Errorf("route (%s) is not available on node %s", nodeName, routeStr)
+ }
+
+ routes, err := json.Marshal(enabledRoutes)
+ if err != nil {
+ return err
+ }
+
+ m.EnabledRoutes = datatypes.JSON(routes)
+ h.db.Save(&m)
+
+ err = h.RequestMapUpdates(m.NamespaceID)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// RoutesToPtables converts the list of routes to a nice table
+func (h *Headscale) RoutesToPtables(namespace string, nodeName string, availableRoutes []netaddr.IPPrefix) pterm.TableData {
+ d := pterm.TableData{{"Route", "Enabled"}}
+
+ for _, route := range availableRoutes {
+ enabled := h.IsNodeRouteEnabled(namespace, nodeName, route.String())
+
+ d = append(d, []string{route.String(), strconv.FormatBool(enabled)})
+ }
+ return d
}
diff --git a/routes_test.go b/routes_test.go
index a05b7e16..ad16d21c 100644
--- a/routes_test.go
+++ b/routes_test.go
@@ -16,7 +16,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
- _, err = h.GetMachine("test", "testmachine")
+ _, err = h.GetMachine("test", "test_get_route_machine")
c.Assert(err, check.NotNil)
route, err := netaddr.ParseIPPrefix("10.0.0.0/24")
@@ -33,7 +33,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
- Name: "testmachine",
+ Name: "test_get_route_machine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
@@ -42,14 +42,87 @@ func (s *Suite) TestGetRoutes(c *check.C) {
}
h.db.Save(&m)
- r, err := h.GetNodeRoutes("test", "testmachine")
+ r, err := h.GetAdvertisedNodeRoutes("test", "test_get_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(*r), check.Equals, 1)
- _, err = h.EnableNodeRoute("test", "testmachine", "192.168.0.0/24")
+ err = h.EnableNodeRoute("test", "test_get_route_machine", "192.168.0.0/24")
c.Assert(err, check.NotNil)
- _, err = h.EnableNodeRoute("test", "testmachine", "10.0.0.0/24")
+ err = h.EnableNodeRoute("test", "test_get_route_machine", "10.0.0.0/24")
+ c.Assert(err, check.IsNil)
+}
+
+func (s *Suite) TestGetEnableRoutes(c *check.C) {
+ n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
+ pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine("test", "test_enable_route_machine")
+ c.Assert(err, check.NotNil)
+
+ route, err := netaddr.ParseIPPrefix(
+ "10.0.0.0/24",
+ )
+ c.Assert(err, check.IsNil)
+
+ route2, err := netaddr.ParseIPPrefix(
+ "150.0.10.0/25",
+ )
+ c.Assert(err, check.IsNil)
+
+ hi := tailcfg.Hostinfo{
+ RoutableIPs: []netaddr.IPPrefix{route, route2},
+ }
+ hostinfo, err := json.Marshal(hi)
+ c.Assert(err, check.IsNil)
+
+ m := Machine{
+ ID: 0,
+ MachineKey: "foo",
+ NodeKey: "bar",
+ DiscoKey: "faa",
+ Name: "test_enable_route_machine",
+ NamespaceID: n.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ AuthKeyID: uint(pak.ID),
+ HostInfo: datatypes.JSON(hostinfo),
+ }
+ h.db.Save(&m)
+
+ availableRoutes, err := h.GetAdvertisedNodeRoutes("test", "test_enable_route_machine")
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*availableRoutes), check.Equals, 2)
+
+ enabledRoutes, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
+ c.Assert(err, check.IsNil)
+ c.Assert(len(enabledRoutes), check.Equals, 0)
+
+ err = h.EnableNodeRoute("test", "test_enable_route_machine", "192.168.0.0/24")
+ c.Assert(err, check.NotNil)
+
+ err = h.EnableNodeRoute("test", "test_enable_route_machine", "10.0.0.0/24")
+ c.Assert(err, check.IsNil)
+
+ enabledRoutes1, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
+ c.Assert(err, check.IsNil)
+ c.Assert(len(enabledRoutes1), check.Equals, 1)
+
+ // Adding it twice will just let it pass through
+ err = h.EnableNodeRoute("test", "test_enable_route_machine", "10.0.0.0/24")
+ c.Assert(err, check.IsNil)
+
+ enabledRoutes2, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
+ c.Assert(err, check.IsNil)
+ c.Assert(len(enabledRoutes2), check.Equals, 1)
+
+ err = h.EnableNodeRoute("test", "test_enable_route_machine", "150.0.10.0/25")
+ c.Assert(err, check.IsNil)
+
+ enabledRoutes3, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
+ c.Assert(err, check.IsNil)
+ c.Assert(len(enabledRoutes3), check.Equals, 2)
}
diff --git a/scripts/version-at-commit.sh b/scripts/version-at-commit.sh
index aebdb870..2f7fab84 100755
--- a/scripts/version-at-commit.sh
+++ b/scripts/version-at-commit.sh
@@ -1,4 +1,4 @@
-#!/bin/bash
+#!/usr/bin/env bash
set -e -o pipefail
commit="$1"
diff --git a/sharing.go b/sharing.go
new file mode 100644
index 00000000..93c299c7
--- /dev/null
+++ b/sharing.go
@@ -0,0 +1,37 @@
+package headscale
+
+import "gorm.io/gorm"
+
+const errorSameNamespace = Error("Destination namespace same as origin")
+const errorMachineAlreadyShared = Error("Node already shared to this namespace")
+
+// SharedMachine is a join table to support sharing nodes between namespaces
+type SharedMachine struct {
+ gorm.Model
+ MachineID uint64
+ Machine Machine
+ NamespaceID uint
+ Namespace Namespace
+}
+
+// AddSharedMachineToNamespace adds a machine as a shared node to a namespace
+func (h *Headscale) AddSharedMachineToNamespace(m *Machine, ns *Namespace) error {
+ if m.NamespaceID == ns.ID {
+ return errorSameNamespace
+ }
+
+ sharedMachine := SharedMachine{}
+ if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sharedMachine).Error; err == nil {
+ return errorMachineAlreadyShared
+ }
+
+ sharedMachine = SharedMachine{
+ MachineID: m.ID,
+ Machine: *m,
+ NamespaceID: ns.ID,
+ Namespace: *ns,
+ }
+ h.db.Save(&sharedMachine)
+
+ return nil
+}
diff --git a/sharing_test.go b/sharing_test.go
new file mode 100644
index 00000000..ec4951de
--- /dev/null
+++ b/sharing_test.go
@@ -0,0 +1,359 @@
+package headscale
+
+import (
+ "gopkg.in/check.v1"
+ "tailscale.com/tailcfg"
+)
+
+func (s *Suite) TestBasicSharedNodesInNamespace(c *check.C) {
+ n1, err := h.CreateNamespace("shared1")
+ c.Assert(err, check.IsNil)
+
+ n2, err := h.CreateNamespace("shared2")
+ c.Assert(err, check.IsNil)
+
+ pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
+ c.Assert(err, check.NotNil)
+
+ m1 := Machine{
+ ID: 0,
+ MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ Name: "test_get_shared_nodes_1",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.1",
+ AuthKeyID: uint(pak1.ID),
+ }
+ h.db.Save(&m1)
+
+ _, err = h.GetMachine(n1.Name, m1.Name)
+ c.Assert(err, check.IsNil)
+
+ m2 := Machine{
+ ID: 1,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_2",
+ NamespaceID: n2.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.2",
+ AuthKeyID: uint(pak2.ID),
+ }
+ h.db.Save(&m2)
+
+ _, err = h.GetMachine(n2.Name, m2.Name)
+ c.Assert(err, check.IsNil)
+
+ p1s, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1s), check.Equals, 0)
+
+ err = h.AddSharedMachineToNamespace(&m2, n1)
+ c.Assert(err, check.IsNil)
+
+ p1sAfter, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1sAfter), check.Equals, 1)
+ c.Assert((*p1sAfter)[0].ID, check.Equals, tailcfg.NodeID(m2.ID))
+}
+
+func (s *Suite) TestSameNamespace(c *check.C) {
+ n1, err := h.CreateNamespace("shared1")
+ c.Assert(err, check.IsNil)
+
+ n2, err := h.CreateNamespace("shared2")
+ c.Assert(err, check.IsNil)
+
+ pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
+ c.Assert(err, check.NotNil)
+
+ m1 := Machine{
+ ID: 0,
+ MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ Name: "test_get_shared_nodes_1",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.1",
+ AuthKeyID: uint(pak1.ID),
+ }
+ h.db.Save(&m1)
+
+ _, err = h.GetMachine(n1.Name, m1.Name)
+ c.Assert(err, check.IsNil)
+
+ m2 := Machine{
+ ID: 1,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_2",
+ NamespaceID: n2.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.2",
+ AuthKeyID: uint(pak2.ID),
+ }
+ h.db.Save(&m2)
+
+ _, err = h.GetMachine(n2.Name, m2.Name)
+ c.Assert(err, check.IsNil)
+
+ p1s, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1s), check.Equals, 0)
+
+ err = h.AddSharedMachineToNamespace(&m1, n1)
+ c.Assert(err, check.Equals, errorSameNamespace)
+}
+
+func (s *Suite) TestAlreadyShared(c *check.C) {
+ n1, err := h.CreateNamespace("shared1")
+ c.Assert(err, check.IsNil)
+
+ n2, err := h.CreateNamespace("shared2")
+ c.Assert(err, check.IsNil)
+
+ pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
+ c.Assert(err, check.NotNil)
+
+ m1 := Machine{
+ ID: 0,
+ MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ Name: "test_get_shared_nodes_1",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.1",
+ AuthKeyID: uint(pak1.ID),
+ }
+ h.db.Save(&m1)
+
+ _, err = h.GetMachine(n1.Name, m1.Name)
+ c.Assert(err, check.IsNil)
+
+ m2 := Machine{
+ ID: 1,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_2",
+ NamespaceID: n2.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.2",
+ AuthKeyID: uint(pak2.ID),
+ }
+ h.db.Save(&m2)
+
+ _, err = h.GetMachine(n2.Name, m2.Name)
+ c.Assert(err, check.IsNil)
+
+ p1s, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1s), check.Equals, 0)
+
+ err = h.AddSharedMachineToNamespace(&m2, n1)
+ c.Assert(err, check.IsNil)
+ err = h.AddSharedMachineToNamespace(&m2, n1)
+ c.Assert(err, check.Equals, errorMachineAlreadyShared)
+}
+
+func (s *Suite) TestDoNotIncludeRoutesOnShared(c *check.C) {
+ n1, err := h.CreateNamespace("shared1")
+ c.Assert(err, check.IsNil)
+
+ n2, err := h.CreateNamespace("shared2")
+ c.Assert(err, check.IsNil)
+
+ pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
+ c.Assert(err, check.NotNil)
+
+ m1 := Machine{
+ ID: 0,
+ MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ Name: "test_get_shared_nodes_1",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.1",
+ AuthKeyID: uint(pak1.ID),
+ }
+ h.db.Save(&m1)
+
+ _, err = h.GetMachine(n1.Name, m1.Name)
+ c.Assert(err, check.IsNil)
+
+ m2 := Machine{
+ ID: 1,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_2",
+ NamespaceID: n2.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.2",
+ AuthKeyID: uint(pak2.ID),
+ }
+ h.db.Save(&m2)
+
+ _, err = h.GetMachine(n2.Name, m2.Name)
+ c.Assert(err, check.IsNil)
+
+ p1s, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1s), check.Equals, 0)
+
+ err = h.AddSharedMachineToNamespace(&m2, n1)
+ c.Assert(err, check.IsNil)
+
+ p1sAfter, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1sAfter), check.Equals, 1)
+ c.Assert(len((*p1sAfter)[0].AllowedIPs), check.Equals, 1)
+}
+
+func (s *Suite) TestComplexSharingAcrossNamespaces(c *check.C) {
+ n1, err := h.CreateNamespace("shared1")
+ c.Assert(err, check.IsNil)
+
+ n2, err := h.CreateNamespace("shared2")
+ c.Assert(err, check.IsNil)
+
+ n3, err := h.CreateNamespace("shared3")
+ c.Assert(err, check.IsNil)
+
+ pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak3, err := h.CreatePreAuthKey(n3.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ pak4, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
+ c.Assert(err, check.NotNil)
+
+ m1 := Machine{
+ ID: 0,
+ MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
+ Name: "test_get_shared_nodes_1",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.1",
+ AuthKeyID: uint(pak1.ID),
+ }
+ h.db.Save(&m1)
+
+ _, err = h.GetMachine(n1.Name, m1.Name)
+ c.Assert(err, check.IsNil)
+
+ m2 := Machine{
+ ID: 1,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_2",
+ NamespaceID: n2.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.2",
+ AuthKeyID: uint(pak2.ID),
+ }
+ h.db.Save(&m2)
+
+ _, err = h.GetMachine(n2.Name, m2.Name)
+ c.Assert(err, check.IsNil)
+
+ m3 := Machine{
+ ID: 2,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_3",
+ NamespaceID: n3.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.3",
+ AuthKeyID: uint(pak3.ID),
+ }
+ h.db.Save(&m3)
+
+ _, err = h.GetMachine(n3.Name, m3.Name)
+ c.Assert(err, check.IsNil)
+
+ m4 := Machine{
+ ID: 3,
+ MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
+ Name: "test_get_shared_nodes_4",
+ NamespaceID: n1.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ IPAddress: "100.64.0.4",
+ AuthKeyID: uint(pak4.ID),
+ }
+ h.db.Save(&m4)
+
+ _, err = h.GetMachine(n1.Name, m4.Name)
+ c.Assert(err, check.IsNil)
+
+ p1s, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1s), check.Equals, 1) // nodes 1 and 4
+
+ err = h.AddSharedMachineToNamespace(&m2, n1)
+ c.Assert(err, check.IsNil)
+
+ p1sAfter, err := h.getPeers(m1)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*p1sAfter), check.Equals, 2) // nodes 1, 2, 4
+
+ pAlone, err := h.getPeers(m3)
+ c.Assert(err, check.IsNil)
+ c.Assert(len(*pAlone), check.Equals, 0) // node 3 is alone
+}
diff --git a/utils.go b/utils.go
index f21063b0..cbe1d870 100644
--- a/utils.go
+++ b/utils.go
@@ -7,18 +7,14 @@ package headscale
import (
"crypto/rand"
- "encoding/binary"
"encoding/json"
- "errors"
"fmt"
"io"
- "net"
- "time"
-
- mathrand "math/rand"
+ "strings"
"golang.org/x/crypto/nacl/box"
- "gorm.io/gorm"
+ "inet.af/netaddr"
+ "tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
)
@@ -64,6 +60,7 @@ func encode(v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, e
if err != nil {
return nil, err
}
+
return encodeMsg(b, pubKey, privKey)
}
@@ -77,47 +74,85 @@ func encodeMsg(b []byte, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, err
return msg, nil
}
-func (h *Headscale) getAvailableIP() (*net.IP, error) {
- i := 0
+func (h *Headscale) getAvailableIP() (*netaddr.IP, error) {
+ ipPrefix := h.cfg.IPPrefix
+
+ usedIps, err := h.getUsedIPs()
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the first IP in our prefix
+ ip := ipPrefix.IP()
+
for {
- ip, err := getRandomIP()
- if err != nil {
- return nil, err
+ if !ipPrefix.Contains(ip) {
+ return nil, fmt.Errorf("could not find any suitable IP in %s", ipPrefix)
}
- m := Machine{}
- if result := h.db.First(&m, "ip_address = ?", ip.String()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return ip, nil
+
+ // Some OS (including Linux) does not like when IPs ends with 0 or 255, which
+ // is typically called network or broadcast. Lets avoid them and continue
+ // to look when we get one of those traditionally reserved IPs.
+ ipRaw := ip.As4()
+ if ipRaw[3] == 0 || ipRaw[3] == 255 {
+ ip = ip.Next()
+ continue
}
- i++
- if i == 100 { // really random number
- break
+
+ if ip.IsZero() &&
+ ip.IsLoopback() {
+
+ ip = ip.Next()
+ continue
}
+
+ if !containsIPs(usedIps, ip) {
+ return &ip, nil
+ }
+
+ ip = ip.Next()
}
- return nil, errors.New("Could not find an available IP address in 100.64.0.0/10")
}
-func getRandomIP() (*net.IP, error) {
- mathrand.Seed(time.Now().Unix())
- ipo, ipnet, err := net.ParseCIDR("100.64.0.0/10")
- if err == nil {
- ip := ipo.To4()
- // fmt.Println("In Randomize IPAddr: IP ", ip, " IPNET: ", ipnet)
- // fmt.Println("Final address is ", ip)
- // fmt.Println("Broadcast address is ", ipb)
- // fmt.Println("Network address is ", ipn)
- r := mathrand.Uint32()
- ipRaw := make([]byte, 4)
- binary.LittleEndian.PutUint32(ipRaw, r)
- // ipRaw[3] = 254
- // fmt.Println("ipRaw is ", ipRaw)
- for i, v := range ipRaw {
- // fmt.Println("IP Before: ", ip[i], " v is ", v, " Mask is: ", ipnet.Mask[i])
- ip[i] = ip[i] + (v &^ ipnet.Mask[i])
- // fmt.Println("IP After: ", ip[i])
+func (h *Headscale) getUsedIPs() ([]netaddr.IP, error) {
+ var addresses []string
+ h.db.Model(&Machine{}).Pluck("ip_address", &addresses)
+
+ ips := make([]netaddr.IP, len(addresses))
+ for index, addr := range addresses {
+ if addr != "" {
+ ip, err := netaddr.ParseIP(addr)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse ip from database, %w", err)
+ }
+
+ ips[index] = ip
}
- // fmt.Println("FINAL IP: ", ip.String())
- return &ip, nil
}
- return nil, err
+ return ips, nil
+}
+
+func containsIPs(ips []netaddr.IP, ip netaddr.IP) bool {
+ for _, v := range ips {
+ if v == ip {
+ return true
+ }
+ }
+
+ return false
+}
+
+func tailNodesToString(nodes []*tailcfg.Node) string {
+ temp := make([]string, len(nodes))
+
+ for index, node := range nodes {
+ temp[index] = node.Name
+ }
+
+ return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
+}
+
+func tailMapResponseToString(resp tailcfg.MapResponse) string {
+ return fmt.Sprintf("{ Node: %s, Peers: %s }", resp.Node.Name, tailNodesToString(resp.Peers))
}
diff --git a/utils_test.go b/utils_test.go
new file mode 100644
index 00000000..f50cd117
--- /dev/null
+++ b/utils_test.go
@@ -0,0 +1,155 @@
+package headscale
+
+import (
+ "gopkg.in/check.v1"
+ "inet.af/netaddr"
+)
+
+func (s *Suite) TestGetAvailableIp(c *check.C) {
+ ip, err := h.getAvailableIP()
+
+ c.Assert(err, check.IsNil)
+
+ expected := netaddr.MustParseIP("10.27.0.1")
+
+ c.Assert(ip.String(), check.Equals, expected.String())
+}
+
+func (s *Suite) TestGetUsedIps(c *check.C) {
+ ip, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ n, err := h.CreateNamespace("test_ip")
+ c.Assert(err, check.IsNil)
+
+ pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine("test", "testmachine")
+ c.Assert(err, check.NotNil)
+
+ m := Machine{
+ ID: 0,
+ MachineKey: "foo",
+ NodeKey: "bar",
+ DiscoKey: "faa",
+ Name: "testmachine",
+ NamespaceID: n.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ AuthKeyID: uint(pak.ID),
+ IPAddress: ip.String(),
+ }
+ h.db.Save(&m)
+
+ ips, err := h.getUsedIPs()
+
+ c.Assert(err, check.IsNil)
+
+ expected := netaddr.MustParseIP("10.27.0.1")
+
+ c.Assert(ips[0], check.Equals, expected)
+
+ m1, err := h.GetMachineByID(0)
+ c.Assert(err, check.IsNil)
+
+ c.Assert(m1.IPAddress, check.Equals, expected.String())
+}
+
+func (s *Suite) TestGetMultiIp(c *check.C) {
+ n, err := h.CreateNamespace("test-ip-multi")
+ c.Assert(err, check.IsNil)
+
+ for i := 1; i <= 350; i++ {
+ ip, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine("test", "testmachine")
+ c.Assert(err, check.NotNil)
+
+ m := Machine{
+ ID: uint64(i),
+ MachineKey: "foo",
+ NodeKey: "bar",
+ DiscoKey: "faa",
+ Name: "testmachine",
+ NamespaceID: n.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ AuthKeyID: uint(pak.ID),
+ IPAddress: ip.String(),
+ }
+ h.db.Save(&m)
+ }
+
+ ips, err := h.getUsedIPs()
+
+ c.Assert(err, check.IsNil)
+
+ c.Assert(len(ips), check.Equals, 350)
+
+ c.Assert(ips[0], check.Equals, netaddr.MustParseIP("10.27.0.1"))
+ c.Assert(ips[9], check.Equals, netaddr.MustParseIP("10.27.0.10"))
+ c.Assert(ips[300], check.Equals, netaddr.MustParseIP("10.27.1.47"))
+
+ // Check that we can read back the IPs
+ m1, err := h.GetMachineByID(1)
+ c.Assert(err, check.IsNil)
+ c.Assert(m1.IPAddress, check.Equals, netaddr.MustParseIP("10.27.0.1").String())
+
+ m50, err := h.GetMachineByID(50)
+ c.Assert(err, check.IsNil)
+ c.Assert(m50.IPAddress, check.Equals, netaddr.MustParseIP("10.27.0.50").String())
+
+ expectedNextIP := netaddr.MustParseIP("10.27.1.97")
+ nextIP, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ c.Assert(nextIP.String(), check.Equals, expectedNextIP.String())
+
+ // If we call get Available again, we should receive
+ // the same IP, as it has not been reserved.
+ nextIP2, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ c.Assert(nextIP2.String(), check.Equals, expectedNextIP.String())
+}
+
+func (s *Suite) TestGetAvailableIpMachineWithoutIP(c *check.C) {
+ ip, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ expected := netaddr.MustParseIP("10.27.0.1")
+
+ c.Assert(ip.String(), check.Equals, expected.String())
+
+ n, err := h.CreateNamespace("test_ip")
+ c.Assert(err, check.IsNil)
+
+ pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
+ c.Assert(err, check.IsNil)
+
+ _, err = h.GetMachine("test", "testmachine")
+ c.Assert(err, check.NotNil)
+
+ m := Machine{
+ ID: 0,
+ MachineKey: "foo",
+ NodeKey: "bar",
+ DiscoKey: "faa",
+ Name: "testmachine",
+ NamespaceID: n.ID,
+ Registered: true,
+ RegisterMethod: "authKey",
+ AuthKeyID: uint(pak.ID),
+ }
+ h.db.Save(&m)
+
+ ip2, err := h.getAvailableIP()
+ c.Assert(err, check.IsNil)
+
+ c.Assert(ip2.String(), check.Equals, expected.String())
+}