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()) +}