From 036061664ef1f1f97058a65eba1d97a82ef2cd20 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 13 Aug 2021 16:12:01 +0100 Subject: [PATCH 01/54] initial integration test file --- .github/workflows/test-integration.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/test-integration.yml 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 From 7d1a5c00a099d103103be9312823a8f3651d61b7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 13 Aug 2021 16:56:28 +0100 Subject: [PATCH 02/54] Try with longer timeout --- integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_test.go b/integration_test.go index dd96fb82..4c1c54b4 100644 --- a/integration_test.go +++ b/integration_test.go @@ -295,7 +295,7 @@ func (s *IntegrationTestSuite) TestPingAllPeers() { s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) { // We currently cant ping ourselves, so skip that. if peername != hostname { - command := []string{"tailscale", "ping", "--timeout=1s", "--c=1", ip.String()} + command := []string{"tailscale", "ping", "--timeout=5s", "--c=1", ip.String()} fmt.Printf("Pinging from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip) result, err := executeCommand( From 6fa61380b25c522d43f4889993ee0ad55b432c5c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Aug 2021 23:17:09 +0100 Subject: [PATCH 03/54] Up client count, make arguments more explicit and clean up unused assignments --- integration_test.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/integration_test.go b/integration_test.go index 4c1c54b4..892c7ece 100644 --- a/integration_test.go +++ b/integration_test.go @@ -34,7 +34,7 @@ var ih Headscale var pool dockertest.Pool var network dockertest.Network var headscale dockertest.Resource -var tailscaleCount int = 5 +var tailscaleCount int = 20 var tailscales map[string]dockertest.Resource func executeCommand(resource *dockertest.Resource, cmd []string) (string, error) { @@ -115,7 +115,6 @@ func (s *IntegrationTestSuite) SetupSuite() { PortBindings: map[docker.Port][]docker.PortBinding{ "8080/tcp": []docker.PortBinding{{HostPort: "8080"}}, }, - Env: []string{}, } fmt.Println("Creating headscale container") @@ -134,7 +133,6 @@ func (s *IntegrationTestSuite) SetupSuite() { Name: hostname, Networks: []*dockertest.Network{&network}, Cmd: []string{"tailscaled", "--tun=userspace-networking", "--socks5-server=localhost:1055"}, - Env: []string{}, } if pts, err := pool.BuildAndRunWithBuildOptions(tailscaleBuildOptions, tailscaleOptions, dockerRestartPolicy); err == nil { @@ -145,7 +143,6 @@ func (s *IntegrationTestSuite) SetupSuite() { fmt.Printf("Created %s container\n", hostname) } - // TODO: Replace this logic with something that can be detected on Github Actions fmt.Println("Waiting for headscale to be ready") hostEndpoint := fmt.Sprintf("localhost:%s", headscale.GetPort("8080/tcp")) @@ -197,20 +194,20 @@ func (s *IntegrationTestSuite) SetupSuite() { // 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(20 * time.Second) + time.Sleep(120 * time.Second) } func (s *IntegrationTestSuite) TearDownSuite() { - if err := pool.Purge(&headscale); err != nil { - log.Printf("Could not purge resource: %s\n", err) - } - for _, tailscale := range tailscales { if err := pool.Purge(&tailscale); err != nil { log.Printf("Could not purge resource: %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) } @@ -295,7 +292,15 @@ func (s *IntegrationTestSuite) TestPingAllPeers() { s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) { // We currently cant ping ourselves, so skip that. if peername != hostname { - command := []string{"tailscale", "ping", "--timeout=5s", "--c=1", ip.String()} + // 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( From 2f883410d2a53f291979260810878d8f062b0771 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Aug 2021 23:17:38 +0100 Subject: [PATCH 04/54] Add lastUpdate field to machine, function issue message on update channel This commit adds a new field to machine, lastSuccessfulUpdate which tracks when we last was able to send a proper mapupdate to the node. The purpose of this is to be able to compare to a "global" last updated time and determine if we need to send an update map request to a node. In addition it allows us to create a scheduled check to see if all known nodes are up to date. Also, add a helper function to send a message to the update channel of a machine. --- machine.go | 81 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/machine.go b/machine.go index 69de453d..14efd008 100644 --- a/machine.go +++ b/machine.go @@ -2,6 +2,7 @@ package headscale import ( "encoding/json" + "errors" "fmt" "sort" "strconv" @@ -31,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 @@ -211,6 +213,13 @@ func (h *Headscale) GetMachineByID(id uint64) (*Machine, error) { return &m, nil } +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 @@ -251,21 +260,67 @@ func (m *Machine) GetHostInfo() (*tailcfg.Hostinfo, error) { func (h *Headscale) notifyChangesToPeers(m *Machine) { peers, _ := h.getPeers(*m) for _, p := range *peers { - pUp, ok := h.clientsPolling.Load(uint64(p.ID)) - if ok { + 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.requestUpdate(p) + if err != nil { log.Info(). Str("func", "notifyChangesToPeers"). Str("machine", m.Name). - Str("peer", m.Name). - Str("address", p.Addresses[0].String()). - Msgf("Notifying peer %s (%s)", p.Name, p.Addresses[0]) - pUp.(chan []byte) <- []byte{} - } else { - log.Info(). - Str("func", "notifyChangesToPeers"). - Str("machine", m.Name). - Str("peer", 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) requestUpdate(m *tailcfg.Node) error { + 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 { + lastChange := h.getLastStateChange() + 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) +} From 57b79aa852973bbc9ac3ab6a952061b83c1d008b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Aug 2021 23:21:11 +0100 Subject: [PATCH 05/54] Set timeout, add lastupdate field This commit makes two reasonably major changes: Set a default timeout for the go HTTP server (which gin uses), which allows us to actually have broken long poll sessions fail so we can have the client re-establish them. The current 10s number is chosen randomly and we need more testing to ensure that the feature work as intended. The second is adding a last updated field to keep track of the last time we had an update that needs to be propagated to all of our clients/nodes. This will be used to keep track of our machines and if they are up to date or need us to push an update. --- app.go | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/app.go b/app.go index fcf287f7..255a7df1 100644 --- a/app.go +++ b/app.go @@ -58,7 +58,10 @@ type Headscale struct { aclPolicy *ACLPolicy aclRules *[]tailcfg.FilterRule - clientsPolling sync.Map + clientsUpdateChannels sync.Map + + lastStateChangeMutex sync.RWMutex + lastStateChange time.Time } // NewHeadscale returns the Headscale app @@ -85,12 +88,13 @@ func NewHeadscale(cfg Config) (*Headscale, error) { } h := Headscale{ - cfg: cfg, - dbType: cfg.DBtype, - dbString: dbString, - privateKey: privKey, - publicKey: &pubKey, - aclRules: &tailcfg.FilterAllowAll, // default allowall + cfg: cfg, + dbType: cfg.DBtype, + dbString: dbString, + privateKey: privKey, + publicKey: &pubKey, + aclRules: &tailcfg.FilterAllowAll, // default allowall + lastStateChange: time.Now().UTC(), } err = h.initDB() @@ -168,6 +172,13 @@ func (h *Headscale) Serve() error { go h.watchForKVUpdates(5000) go h.expireEphemeralNodes(5000) + s := &http.Server{ + Addr: h.cfg.Addr, + Handler: r, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + if h.cfg.TLSLetsEncryptHostname != "" { if !strings.HasPrefix(h.cfg.ServerURL, "https://") { log.Warn().Msg("Listening with TLS but ServerURL does not start with https://") @@ -179,9 +190,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: 10 * time.Second, + WriteTimeout: 10 * time.Second, } if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" { // Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737) @@ -206,12 +219,27 @@ func (h *Headscale) Serve() error { if !strings.HasPrefix(h.cfg.ServerURL, "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.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() { + h.lastStateChangeMutex.Lock() + + now := time.Now().UTC() + h.lastStateChange = now + + h.lastStateChangeMutex.Unlock() +} + +func (h *Headscale) getLastStateChange() time.Time { + h.lastStateChangeMutex.RLock() + defer h.lastStateChangeMutex.RUnlock() + return h.lastStateChange +} From dd8c0d1e9e2415247fc37414ed468ec25e8c5f37 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 18 Aug 2021 23:24:22 +0100 Subject: [PATCH 06/54] Move most "poll" functionality to poll.go This function migrates more poll functions (including keepalive) to poll.go to keep it somehow in the same file. In addition it makes changes to improve the stability and ensure nodes get the appropriate updates from the headscale control and are not left in an inconsistent state. Two new additions is: omitpeers=true will now trigger an update if the clients are not already up to date keepalive has been extended with a timer that will check every 120s if all nodes are up to date. --- api.go | 203 ++---------------------------- poll.go | 385 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 365 insertions(+), 223 deletions(-) diff --git a/api.go b/api.go index 0dc2bec5..7a6b4b16 100644 --- a/api.go +++ b/api.go @@ -13,7 +13,6 @@ import ( "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" - "gorm.io/datatypes" "gorm.io/gorm" "inet.af/netaddr" "tailscale.com/tailcfg" @@ -82,14 +81,16 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) { 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.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.Error(). @@ -215,196 +216,6 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) { 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) { - 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. Asking for DERP map") - c.Data(200, "application/json; charset=utf-8", *data) - return - } - 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) - 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 - } - - // Only create update channel if it has not been created - var update chan []byte - log.Trace(). - Str("handler", "PollNetMap"). - Str("id", c.Param("id")). - Str("machine", m.Name). - Msg("Creating or loading update channel") - if result, ok := h.clientsPolling.LoadOrStore(m.ID, make(chan []byte, 1)); ok { - update = result.(chan []byte) - } - - pollData := make(chan []byte, 1) - defer close(pollData) - - cancelKeepAlive := make(chan []byte, 1) - defer close(cancelKeepAlive) - - 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") - pollData <- *data - - log.Info(). - Str("handler", "PollNetMap"). - Str("machine", m.Name). - Msg("Notifying peers") - // TODO: Why does this block? - go h.notifyChangesToPeers(&m) - - h.PollNetMapStream(c, m, req, mKey, pollData, update, cancelKeepAlive) - log.Trace(). - Str("handler", "PollNetMap"). - Str("id", c.Param("id")). - Str("machine", m.Name). - Msg("Finished stream, closing PollNetMap session") -} - -func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgkey.Key, req tailcfg.MapRequest, m Machine) { - for { - select { - case <-cancel: - return - - default: - 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") - pollData <- *data - - time.Sleep(60 * time.Second) - } - } -} - func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) { log.Trace(). Str("func", "getMapResponse"). @@ -542,7 +353,7 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, Str("func", "handleAuthKey"). Str("machine", m.Name). Str("ip", ip.String()). - Msgf("Assining %s to %s", ip, m.Name) + Msgf("Assigning %s to %s", ip, m.Name) m.AuthKeyID = uint(pak.ID) m.IPAddress = ip.String() diff --git a/poll.go b/poll.go index f0bfe706..d58d45f1 100644 --- a/poll.go +++ b/poll.go @@ -1,38 +1,242 @@ 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() + + // 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") + var updateChan chan struct{} + if storedChan, ok := h.clientsUpdateChannels.Load(m.ID); ok { + if wrapped, ok := storedChan.(chan struct{}); ok { + updateChan = wrapped + } else { + log.Error(). + Str("handler", "PollNetMap"). + Str("id", c.Param("id")). + Str("machine", m.Name). + Msg("Failed to convert update channel to struct{}") + } + } else { + log.Debug(). + Str("handler", "PollNetMap"). + Str("id", c.Param("id")). + Str("machine", m.Name). + Msg("Update channel not found, creating") + + updateChan = make(chan struct{}) + h.clientsUpdateChannels.Store(m.ID, updateChan) + } + + 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") +} + func (h *Headscale) PollNetMapStream( c *gin.Context, m Machine, req tailcfg.MapRequest, mKey wgkey.Key, - pollData chan []byte, - update chan []byte, - cancelKeepAlive chan []byte, + pollDataChan chan []byte, + keepAliveChan chan []byte, + updateChan chan struct{}, + cancelKeepAlive chan struct{}, ) { - - go h.keepAlive(cancelKeepAlive, pollData, mKey, req, m) + go h.keepAlive(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...") - select { - case data := <-pollData: + 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) @@ -40,44 +244,99 @@ func (h *Headscale) PollNetMapStream( 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") 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") + 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 pollData") + Msg("Machine updated successfully after sending keep alive") return true - case <-update: - log.Debug(). - Str("handler", "PollNetMapStream"). - Str("machine", m.Name). - Msg("Received a request for update") - data, err := h.getMapResponse(mKey, req, m) - if err != nil { - log.Error(). + case <-updateChan: + if h.isOutdated(&m) { + log.Trace(). Str("handler", "PollNetMapStream"). Str("machine", m.Name). - Err(err). - Msg("Could not get the map update") - } - _, err = w.Write(*data) - if err != nil { - log.Error(). + Str("channel", "update"). + Msg("Received a request for update") + 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). - Err(err). - Msg("Could not write the map response") + 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. + now := time.Now().UTC() + m.LastSuccessfulUpdate = &now + h.db.Save(&m) } return true @@ -89,10 +348,82 @@ func (h *Headscale) PollNetMapStream( now := time.Now().UTC() m.LastSeen = &now h.db.Save(&m) - cancelKeepAlive <- []byte{} - h.clientsPolling.Delete(m.ID) - close(update) + + cancelKeepAlive <- struct{}{} + + h.clientsUpdateChannels.Delete(m.ID) + // close(updateChan) + + close(pollDataChan) + + close(keepAliveChan) + return false } }) } + +// TODO: Rename this function to schedule ... +func (h *Headscale) keepAlive( + 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: + err := h.UpdateMachine(&m) + if err != nil { + log.Error(). + Str("func", "keepAlive"). + Str("machine", m.Name). + Err(err). + Msg("Could not refresh machine details from database") + return + } + if h.isOutdated(&m) { + log.Debug(). + Str("func", "keepAlive"). + Str("machine", m.Name). + Time("last_successful_update", *m.LastSuccessfulUpdate). + Time("last_state_change", h.getLastStateChange()). + Msgf("There has been updates since the last successful update to %s", m.Name) + + // TODO Error checking + n, _ := m.toNode() + h.requestUpdate(n) + } else { + log.Trace(). + Str("func", "keepAlive"). + Str("machine", m.Name). + Time("last_successful_update", *m.LastSuccessfulUpdate). + Time("last_state_change", h.getLastStateChange()). + Msgf("%s is up to date", m.Name) + } + } + } +} From 8d1adaaef3abac891f6794c63268b5ac47c747e2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 19 Aug 2021 18:05:33 +0100 Subject: [PATCH 07/54] Move isOutdated logic to updateChan consumation --- machine.go | 5 +++++ poll.go | 46 +++++++++++++++++++++------------------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/machine.go b/machine.go index 14efd008..13e3529a 100644 --- a/machine.go +++ b/machine.go @@ -315,6 +315,11 @@ func (h *Headscale) requestUpdate(m *tailcfg.Node) error { } func (h *Headscale) isOutdated(m *Machine) bool { + err := h.UpdateMachine(m) + if err != nil { + return true + } + lastChange := h.getLastStateChange() log.Trace(). Str("func", "keepAlive"). diff --git a/poll.go b/poll.go index d58d45f1..27358fc5 100644 --- a/poll.go +++ b/poll.go @@ -300,12 +300,18 @@ func (h *Headscale) PollNetMapStream( 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.Trace(). + log.Debug(). Str("handler", "PollNetMapStream"). Str("machine", m.Name). - Str("channel", "update"). - Msg("Received a request for update") + Time("last_successful_update", *m.LastSuccessfulUpdate). + Time("last_state_change", h.getLastStateChange()). + 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(). @@ -337,6 +343,13 @@ func (h *Headscale) PollNetMapStream( 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()). + Msgf("%s is up to date", m.Name) } return true @@ -396,33 +409,16 @@ func (h *Headscale) keepAlive( keepAliveChan <- *data case <-updateCheckerTicker.C: - err := h.UpdateMachine(&m) + // 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() + err := h.requestUpdate(n) if err != nil { log.Error(). Str("func", "keepAlive"). Str("machine", m.Name). Err(err). - Msg("Could not refresh machine details from database") - return - } - if h.isOutdated(&m) { - log.Debug(). - Str("func", "keepAlive"). - Str("machine", m.Name). - Time("last_successful_update", *m.LastSuccessfulUpdate). - Time("last_state_change", h.getLastStateChange()). - Msgf("There has been updates since the last successful update to %s", m.Name) - - // TODO Error checking - n, _ := m.toNode() - h.requestUpdate(n) - } else { - log.Trace(). - Str("func", "keepAlive"). - Str("machine", m.Name). - Time("last_successful_update", *m.LastSuccessfulUpdate). - Time("last_state_change", h.getLastStateChange()). - Msgf("%s is up to date", m.Name) + Msgf("Failed to send update request to %s", m.Name) } } } From 48ef6e5a6f492d80aae8e7467498b4053f11da58 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 19 Aug 2021 18:06:57 +0100 Subject: [PATCH 08/54] Rename keepAlive function, as it now does more things --- poll.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/poll.go b/poll.go index 27358fc5..522c529e 100644 --- a/poll.go +++ b/poll.go @@ -218,7 +218,7 @@ func (h *Headscale) PollNetMapStream( updateChan chan struct{}, cancelKeepAlive chan struct{}, ) { - go h.keepAlive(cancelKeepAlive, keepAliveChan, mKey, req, m) + go h.scheduledPollWorker(cancelKeepAlive, keepAliveChan, mKey, req, m) c.Stream(func(w io.Writer) bool { log.Trace(). @@ -376,8 +376,7 @@ func (h *Headscale) PollNetMapStream( }) } -// TODO: Rename this function to schedule ... -func (h *Headscale) keepAlive( +func (h *Headscale) scheduledPollWorker( cancelChan <-chan struct{}, keepAliveChan chan<- []byte, mKey wgkey.Key, From b0ec945dbb59196b7542386a2a543ddf5ba987b9 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 19 Aug 2021 18:19:26 +0100 Subject: [PATCH 09/54] Make lastStateChange namespaced --- app.go | 40 ++++++++++++++++++++-------------------- machine.go | 2 +- poll.go | 6 +++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app.go b/app.go index 255a7df1..76cf92ea 100644 --- a/app.go +++ b/app.go @@ -60,8 +60,7 @@ type Headscale struct { clientsUpdateChannels sync.Map - lastStateChangeMutex sync.RWMutex - lastStateChange time.Time + lastStateChange sync.Map } // NewHeadscale returns the Headscale app @@ -88,13 +87,12 @@ func NewHeadscale(cfg Config) (*Headscale, error) { } h := Headscale{ - cfg: cfg, - dbType: cfg.DBtype, - dbString: dbString, - privateKey: privKey, - publicKey: &pubKey, - aclRules: &tailcfg.FilterAllowAll, // default allowall - lastStateChange: time.Now().UTC(), + cfg: cfg, + dbType: cfg.DBtype, + dbString: dbString, + privateKey: privKey, + publicKey: &pubKey, + aclRules: &tailcfg.FilterAllowAll, // default allowall } err = h.initDB() @@ -229,17 +227,19 @@ func (h *Headscale) Serve() error { return err } -func (h *Headscale) setLastStateChangeToNow() { - h.lastStateChangeMutex.Lock() +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 = now - - h.lastStateChangeMutex.Unlock() -} - -func (h *Headscale) getLastStateChange() time.Time { - h.lastStateChangeMutex.RLock() - defer h.lastStateChangeMutex.RUnlock() - return h.lastStateChange + h.lastStateChange.Store(namespace, now) + return now } diff --git a/machine.go b/machine.go index 13e3529a..5352f741 100644 --- a/machine.go +++ b/machine.go @@ -320,7 +320,7 @@ func (h *Headscale) isOutdated(m *Machine) bool { return true } - lastChange := h.getLastStateChange() + lastChange := h.getLastStateChange(m.Namespace.Name) log.Trace(). Str("func", "keepAlive"). Str("machine", m.Name). diff --git a/poll.go b/poll.go index 522c529e..e85c7a9f 100644 --- a/poll.go +++ b/poll.go @@ -123,7 +123,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) { // There has been an update to _any_ of the nodes that the other nodes would // need to know about - h.setLastStateChangeToNow() + h.setLastStateChangeToNow(m.Namespace.Name) // The request is not ReadOnly, so we need to set up channels for updating // peers via longpoll @@ -310,7 +310,7 @@ func (h *Headscale) PollNetMapStream( Str("handler", "PollNetMapStream"). Str("machine", m.Name). Time("last_successful_update", *m.LastSuccessfulUpdate). - Time("last_state_change", h.getLastStateChange()). + 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 { @@ -348,7 +348,7 @@ func (h *Headscale) PollNetMapStream( Str("handler", "PollNetMapStream"). Str("machine", m.Name). Time("last_successful_update", *m.LastSuccessfulUpdate). - Time("last_state_change", h.getLastStateChange()). + Time("last_state_change", h.getLastStateChange(m.Namespace.Name)). Msgf("%s is up to date", m.Name) } return true From 53168d54d803ef0c4a182ed6457a95e5681657e7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 19 Aug 2021 22:29:03 +0100 Subject: [PATCH 10/54] Make http timeout 30s instead of 10s --- app.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app.go b/app.go index 76cf92ea..fe1b954b 100644 --- a/app.go +++ b/app.go @@ -167,14 +167,16 @@ func (h *Headscale) Serve() error { r.POST("/machine/:id", h.RegistrationHandler) 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: 10 * time.Second, - WriteTimeout: 10 * time.Second, + ReadTimeout: timeout, + WriteTimeout: timeout, } if h.cfg.TLSLetsEncryptHostname != "" { @@ -191,8 +193,8 @@ func (h *Headscale) Serve() error { Addr: h.cfg.Addr, TLSConfig: m.TLSConfig(), Handler: r, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, + ReadTimeout: timeout, + WriteTimeout: timeout, } if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" { // Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737) From 1f422af1c80e7226b8555a660338bbd30b4ef36c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 20 Aug 2021 16:50:55 +0100 Subject: [PATCH 11/54] Save headscale logs if jobs fail --- .gitignore | 2 + integration_test.go | 91 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 3a64648f..44bec691 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ config.json *.key /db.sqlite *.sqlite3 + +test_output/ diff --git a/integration_test.go b/integration_test.go index 892c7ece..fa0dfbf0 100644 --- a/integration_test.go +++ b/integration_test.go @@ -4,10 +4,13 @@ package headscale import ( "bytes" + "context" "fmt" + "io/ioutil" "log" "net/http" "os" + "path" "strings" "testing" "time" @@ -22,10 +25,35 @@ import ( type IntegrationTestSuite struct { suite.Suite + stats *suite.SuiteInformation } func TestIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(IntegrationTestSuite)) + 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) + } } var integrationTmpDir string @@ -34,7 +62,7 @@ var ih Headscale var pool dockertest.Pool var network dockertest.Network var headscale dockertest.Resource -var tailscaleCount int = 20 +var tailscaleCount int = 25 var tailscales map[string]dockertest.Resource func executeCommand(resource *dockertest.Resource, cmd []string) (string, error) { @@ -62,6 +90,48 @@ func executeCommand(resource *dockertest.Resource, cmd []string) (string, error) 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 @@ -194,23 +264,14 @@ func (s *IntegrationTestSuite) SetupSuite() { // 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(120 * time.Second) + time.Sleep(60 * time.Second) } func (s *IntegrationTestSuite) TearDownSuite() { - for _, tailscale := range tailscales { - if err := pool.Purge(&tailscale); err != nil { - log.Printf("Could not purge resource: %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 (s *IntegrationTestSuite) HandleStats(suiteName string, stats *suite.SuiteInformation) { + s.stats = stats } func (s *IntegrationTestSuite) TestListNodes() { From 88d7ac04bf7a78f378cd3015c1b1bb083ba54cb3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 20 Aug 2021 16:52:34 +0100 Subject: [PATCH 12/54] Account for racecondition in deleting/closing update channel This commit tries to address the possible raceondition that can happen if a client closes its connection after we have fetched it from the syncmap before sending the message. To try to avoid introducing new dead lock conditions, all messages sent to updateChannel has been moved into a function, which handles the locking (instead of calling it all over the place) The same lock is used around the delete/close function. --- app.go | 3 ++- machine.go | 42 ++++++++++++++++++++++++++++++++++++++++-- poll.go | 29 ++++------------------------- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/app.go b/app.go index fe1b954b..e5f44103 100644 --- a/app.go +++ b/app.go @@ -58,7 +58,8 @@ type Headscale struct { aclPolicy *ACLPolicy aclRules *[]tailcfg.FilterRule - clientsUpdateChannels sync.Map + clientsUpdateChannels sync.Map + clientsUpdateChannelMutex sync.Mutex lastStateChange sync.Map } diff --git a/machine.go b/machine.go index 5352f741..57c48ba8 100644 --- a/machine.go +++ b/machine.go @@ -266,7 +266,7 @@ func (h *Headscale) notifyChangesToPeers(m *Machine) { Str("peer", p.Name). Str("address", p.Addresses[0].String()). Msgf("Notifying peer %s (%s)", p.Name, p.Addresses[0]) - err := h.requestUpdate(p) + err := h.sendRequestOnUpdateChannel(p) if err != nil { log.Info(). Str("func", "notifyChangesToPeers"). @@ -283,7 +283,45 @@ func (h *Headscale) notifyChangesToPeers(m *Machine) { } } -func (h *Headscale) requestUpdate(m *tailcfg.Node) error { +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(). diff --git a/poll.go b/poll.go index e85c7a9f..d086fc44 100644 --- a/poll.go +++ b/poll.go @@ -134,27 +134,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) { Str("id", c.Param("id")). Str("machine", m.Name). Msg("Loading or creating update channel") - var updateChan chan struct{} - if storedChan, ok := h.clientsUpdateChannels.Load(m.ID); ok { - if wrapped, ok := storedChan.(chan struct{}); ok { - updateChan = wrapped - } else { - log.Error(). - Str("handler", "PollNetMap"). - Str("id", c.Param("id")). - Str("machine", m.Name). - Msg("Failed to convert update channel to struct{}") - } - } else { - log.Debug(). - Str("handler", "PollNetMap"). - Str("id", c.Param("id")). - Str("machine", m.Name). - Msg("Update channel not found, creating") - - updateChan = make(chan struct{}) - h.clientsUpdateChannels.Store(m.ID, updateChan) - } + updateChan := h.getOrOpenUpdateChannel(&m) pollDataChan := make(chan []byte) // defer close(pollData) @@ -215,7 +195,7 @@ func (h *Headscale) PollNetMapStream( mKey wgkey.Key, pollDataChan chan []byte, keepAliveChan chan []byte, - updateChan chan struct{}, + updateChan <-chan struct{}, cancelKeepAlive chan struct{}, ) { go h.scheduledPollWorker(cancelKeepAlive, keepAliveChan, mKey, req, m) @@ -364,8 +344,7 @@ func (h *Headscale) PollNetMapStream( cancelKeepAlive <- struct{}{} - h.clientsUpdateChannels.Delete(m.ID) - // close(updateChan) + h.closeUpdateChannel(&m) close(pollDataChan) @@ -411,7 +390,7 @@ func (h *Headscale) scheduledPollWorker( // 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() - err := h.requestUpdate(n) + err := h.sendRequestOnUpdateChannel(n) if err != nil { log.Error(). Str("func", "keepAlive"). From d93a7f2e02a4994a7b7f2c96630c4bde9fd989b4 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 20 Aug 2021 17:15:07 +0100 Subject: [PATCH 13/54] Make Info default log level --- cmd/headscale/cli/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 4ada6408..7e7e8f96 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -39,7 +39,7 @@ func LoadConfig(path string) error { viper.SetDefault("ip_prefix", "100.64.0.0/10") - viper.SetDefault("log_level", "debug") + viper.SetDefault("log_level", "info") err := viper.ReadInConfig() if err != nil { From c49fe26da7700592cbf7ef04f3ed1787cfeb08ef Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 09:15:16 +0100 Subject: [PATCH 14/54] Code clean up, loglevel debug for integration tests --- integration_test.go | 18 +++++++++--------- integration_test/etc/config.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/integration_test.go b/integration_test.go index fa0dfbf0..98cb925a 100644 --- a/integration_test.go +++ b/integration_test.go @@ -23,6 +23,15 @@ import ( "inet.af/netaddr" ) +var integrationTmpDir string +var ih Headscale + +var pool dockertest.Pool +var network dockertest.Network +var headscale dockertest.Resource +var tailscaleCount int = 50 +var tailscales map[string]dockertest.Resource + type IntegrationTestSuite struct { suite.Suite stats *suite.SuiteInformation @@ -56,15 +65,6 @@ func TestIntegrationTestSuite(t *testing.T) { } } -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 - func executeCommand(resource *dockertest.Resource, cmd []string) (string, error) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/integration_test/etc/config.json b/integration_test/etc/config.json index 5454f2f7..8a6fd962 100644 --- a/integration_test/etc/config.json +++ b/integration_test/etc/config.json @@ -7,5 +7,5 @@ "db_type": "sqlite3", "db_path": "/tmp/integration_test_db.sqlite3", "acl_policy_path": "", - "log_level": "trace" + "log_level": "debug" } From a054e2514ae363ea78052775b3fad44864f3a604 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 09:26:18 +0100 Subject: [PATCH 15/54] Keep tailscale count at 25 in integration tests --- integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_test.go b/integration_test.go index 98cb925a..8cdc1918 100644 --- a/integration_test.go +++ b/integration_test.go @@ -29,7 +29,7 @@ var ih Headscale var pool dockertest.Pool var network dockertest.Network var headscale dockertest.Resource -var tailscaleCount int = 50 +var tailscaleCount int = 25 var tailscales map[string]dockertest.Resource type IntegrationTestSuite struct { From c883e798849edb3d3babe25c24dba9d832da6bf8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 14:49:46 +0100 Subject: [PATCH 16/54] Enhance route command with ptables and multiple routes This commit rewrites the `routes list` command to use ptables to present a slightly nicer list, including a new field if the route is enabled or not (which is quite useful). In addition, it reworks the enable command to support enabling multiple routes (not only one route as per removed TODO). This allows users to actually take advantage of exit-nodes and subnet relays. --- cmd/headscale/cli/routes.go | 28 +++++--- routes.go | 129 ++++++++++++++++++++++++++++++------ routes_test.go | 81 ++++++++++++++++++++-- 3 files changed, 202 insertions(+), 36 deletions(-) diff --git a/cmd/headscale/cli/routes.go b/cmd/headscale/cli/routes.go index 98b653f9..f58d4990 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" ) @@ -44,19 +45,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) + } }, } @@ -80,9 +87,10 @@ var enableRouteCmd = &cobra.Command{ if err != nil { log.Fatalf("Error initializing: %s", err) } - route, err := h.EnableNodeRoute(n, args[0], args[1]) + + err = h.EnableNodeRoute(n, args[0], args[1]) if strings.HasPrefix(o, "json") { - JsonOutput(route, err, o) + JsonOutput(args[1], err, o) return } @@ -90,6 +98,6 @@ var enableRouteCmd = &cobra.Command{ fmt.Println(err) return } - fmt.Printf("Enabled route %s\n", route) + fmt.Printf("Enabled route %s\n", args[1]) }, } diff --git a/routes.go b/routes.go index 202754b1..28d86837 100644 --- a/routes.go +++ b/routes.go @@ -2,55 +2,140 @@ 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 +} + +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 + } - err = h.RequestMapUpdates(m.NamespaceID) - if err != nil { - return nil, err + 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) } - 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 +} + +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..33aaa9df 100644 --- a/routes_test.go +++ b/routes_test.go @@ -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", "testmachine") 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", "testmachine", "192.168.0.0/24") c.Assert(err, check.NotNil) - _, err = h.EnableNodeRoute("test", "testmachine", "10.0.0.0/24") + err = h.EnableNodeRoute("test", "testmachine", "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", "testmachine") + 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", "testmachine") + c.Assert(err, check.IsNil) + c.Assert(len(*availableRoutes), check.Equals, 2) + + enabledRoutes, err := h.GetEnabledNodeRoutes("test", "testmachine") + c.Assert(err, check.IsNil) + c.Assert(len(enabledRoutes), check.Equals, 0) + + err = h.EnableNodeRoute("test", "testmachine", "192.168.0.0/24") + c.Assert(err, check.NotNil) + + err = h.EnableNodeRoute("test", "testmachine", "10.0.0.0/24") + c.Assert(err, check.IsNil) + + enabledRoutes1, err := h.GetEnabledNodeRoutes("test", "testmachine") + 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", "testmachine", "10.0.0.0/24") + c.Assert(err, check.IsNil) + + enabledRoutes2, err := h.GetEnabledNodeRoutes("test", "testmachine") + c.Assert(err, check.IsNil) + c.Assert(len(enabledRoutes2), check.Equals, 1) + + err = h.EnableNodeRoute("test", "testmachine", "150.0.10.0/25") + c.Assert(err, check.IsNil) + + enabledRoutes3, err := h.GetEnabledNodeRoutes("test", "testmachine") + c.Assert(err, check.IsNil) + c.Assert(len(enabledRoutes3), check.Equals, 2) } From 4f97e077db683ff03f6e558510c4117c88ba94cd Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 15:04:30 +0100 Subject: [PATCH 17/54] Add --all flag to routes enable command to enable all advertised routes --- cmd/headscale/cli/routes.go | 68 ++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/cmd/headscale/cli/routes.go b/cmd/headscale/cli/routes.go index f58d4990..72010864 100644 --- a/cmd/headscale/cli/routes.go +++ b/cmd/headscale/cli/routes.go @@ -16,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) } @@ -71,33 +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) } - err = h.EnableNodeRoute(n, args[0], args[1]) - if strings.HasPrefix(o, "json") { - JsonOutput(args[1], err, o) - return - } + if all { + availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0]) + if err != nil { + fmt.Println(err) + return + } - 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", args[1]) }, } From 693bce1b1050098aeee342c5dfd77a0735ea8fd5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 15:35:26 +0100 Subject: [PATCH 18/54] Update test machine name properly --- routes_test.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/routes_test.go b/routes_test.go index 33aaa9df..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") @@ -42,14 +42,14 @@ func (s *Suite) TestGetRoutes(c *check.C) { } h.db.Save(&m) - r, err := h.GetAdvertisedNodeRoutes("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) } @@ -60,7 +60,7 @@ func (s *Suite) TestGetEnableRoutes(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_enable_route_machine") c.Assert(err, check.NotNil) route, err := netaddr.ParseIPPrefix( @@ -93,36 +93,36 @@ func (s *Suite) TestGetEnableRoutes(c *check.C) { } h.db.Save(&m) - availableRoutes, err := h.GetAdvertisedNodeRoutes("test", "testmachine") + 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", "testmachine") + 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", "testmachine", "192.168.0.0/24") + err = h.EnableNodeRoute("test", "test_enable_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_enable_route_machine", "10.0.0.0/24") c.Assert(err, check.IsNil) - enabledRoutes1, err := h.GetEnabledNodeRoutes("test", "testmachine") + 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", "testmachine", "10.0.0.0/24") + err = h.EnableNodeRoute("test", "test_enable_route_machine", "10.0.0.0/24") c.Assert(err, check.IsNil) - enabledRoutes2, err := h.GetEnabledNodeRoutes("test", "testmachine") + 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", "testmachine", "150.0.10.0/25") + err = h.EnableNodeRoute("test", "test_enable_route_machine", "150.0.10.0/25") c.Assert(err, check.IsNil) - enabledRoutes3, err := h.GetEnabledNodeRoutes("test", "testmachine") + enabledRoutes3, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine") c.Assert(err, check.IsNil) c.Assert(len(enabledRoutes3), check.Equals, 2) } From f749be1490f06e48b86e33c13e5cc8c5910cecda Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 15:40:27 +0100 Subject: [PATCH 19/54] Split lint and test CI files This commit splits the lint and test steps into two different jobs in github actions. Consider this a suggestion, the idea is that when we look at PRs we will see explicitly which one of the two types of checks fails without having to open Github actions. --- .github/workflows/lint.yml | 39 ++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 46 ++++++++++++++------------------------ 2 files changed, 56 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..cff42e96 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: CI + +on: [push, pull_request] + +jobs: + # The "build" workflow + 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 + + # 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. + + # 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/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 From 28ed8a5742adc73598a64ed10abad20886cd5268 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 15:42:23 +0100 Subject: [PATCH 20/54] Actually rename lint --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cff42e96..98dbc464 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,7 +4,7 @@ on: [push, pull_request] jobs: # The "build" workflow - test: + lint: # The type of runner that the job will run on runs-on: ubuntu-latest From 0aeeaac3614737861c53ae2ef5a956736d94fbc6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 21 Aug 2021 16:52:19 +0100 Subject: [PATCH 21/54] Always load machine object from DB before save/modify We are currently holding Machine objects in memory for a long time, while waiting for stream/longpoll, this might make us end up with stale objects, that we just call save on, potentially overwriting stuff in the database. A typical scenario would be someone changing something from the CLI, e.g. enabling routes, which in turn is overwritten again by the stale object in the longpolling function. The code has been left with TODO's and a discussion is available in #93. --- poll.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/poll.go b/poll.go index d086fc44..fdf522cd 100644 --- a/poll.go +++ b/poll.go @@ -234,6 +234,18 @@ func (h *Headscale) PollNetMapStream( 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 @@ -268,6 +280,18 @@ func (h *Headscale) PollNetMapStream( 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) @@ -316,10 +340,22 @@ func (h *Headscale) PollNetMapStream( 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. + // 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) @@ -338,6 +374,18 @@ func (h *Headscale) PollNetMapStream( 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) From ebd27b46afabb3f160151e61c96b868e5cff3b2a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 23 Aug 2021 07:35:44 +0100 Subject: [PATCH 22/54] Add comment to updatemachine --- machine.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/machine.go b/machine.go index 57c48ba8..4cdadd98 100644 --- a/machine.go +++ b/machine.go @@ -213,6 +213,8 @@ func (h *Headscale) GetMachineByID(id uint64) (*Machine, error) { return &m, nil } +// 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 From 059f13fc9d8db9066c850433f5cb611746d17338 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 23 Aug 2021 07:38:14 +0100 Subject: [PATCH 23/54] Add missing comment for stream function --- poll.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/poll.go b/poll.go index fdf522cd..bea16164 100644 --- a/poll.go +++ b/poll.go @@ -188,6 +188,9 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) { 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, From 987bbee1dbe9de611bc409e0f39647e460fda25e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 24 Aug 2021 07:09:47 +0100 Subject: [PATCH 24/54] Add DNSConfig field to configuration --- app.go | 2 ++ cmd/headscale/cli/utils.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/app.go b/app.go index e5f44103..c903d83f 100644 --- a/app.go +++ b/app.go @@ -43,6 +43,8 @@ type Config struct { TLSCertPath string TLSKeyPath string + + DNSConfig *tailcfg.DNSConfig } // Headscale represents the base app of the service diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 7e7e8f96..7ba78648 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -41,6 +41,8 @@ func LoadConfig(path string) error { 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) @@ -70,6 +72,40 @@ 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)) + + 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 + } + + dnsConfig.Nameservers = nameservers + } + if viper.IsSet("dns_config.domains") { + dnsConfig.Domains = viper.GetStringSlice("dns_config.domains") + } + + return dnsConfig + } + + return nil } func absPath(path string) string { @@ -126,6 +162,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) From e77c16b55a949d4d181a839771269e6335d03916 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 24 Aug 2021 07:10:09 +0100 Subject: [PATCH 25/54] Add DNSConfig to example and setup test --- cmd/headscale/headscale_test.go | 3 ++- config.json.postgres.example | 7 ++++++- config.json.sqlite.example | 7 ++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index 8fcf8a54..58a0977e 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,7 @@ 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 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" + ] + } } From 01e781e546299627ecf47f5750bb211b15b99b0d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 24 Aug 2021 07:11:45 +0100 Subject: [PATCH 26/54] Pass DNSConfig to nodes in MapResponse --- api.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/api.go b/api.go index 7a6b4b16..621eeb8f 100644 --- a/api.go +++ b/api.go @@ -14,7 +14,6 @@ import ( "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" "gorm.io/gorm" - "inet.af/netaddr" "tailscale.com/tailcfg" "tailscale.com/types/wgkey" ) @@ -245,10 +244,15 @@ 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, From 104776ee84777999ba34e82afe6eec4319704017 Mon Sep 17 00:00:00 2001 From: Aaron Bieber Date: Tue, 24 Aug 2021 07:49:15 -0600 Subject: [PATCH 27/54] fix setting of version --- Makefile | 2 +- scripts/version-at-commit.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 7ffe1f9c..8adf760f 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ 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 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" From b3732e7fb9d9dd28da35ac9c9b62a99e483da860 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 25 Aug 2021 07:04:48 +0100 Subject: [PATCH 28/54] Add nameserver as resolver aswell --- cmd/headscale/cli/utils.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 7ba78648..b5c7c219 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -83,6 +83,7 @@ func getDNSConfig() *tailcfg.DNSConfig { 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) @@ -94,9 +95,13 @@ func getDNSConfig() *tailcfg.DNSConfig { } nameservers[index] = nameserver + resolvers[index] = tailcfg.DNSResolver{ + Addr: nameserver.String() + ":53", + } } dnsConfig.Nameservers = nameservers + dnsConfig.Resolvers = resolvers } if viper.IsSet("dns_config.domains") { dnsConfig.Domains = viper.GetStringSlice("dns_config.domains") From 3f5e06a0f8c4344dcd41a7d433aa7ff1627c2d91 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 25 Aug 2021 18:43:13 +0100 Subject: [PATCH 29/54] Dont add the portnumber to the ip --- cmd/headscale/cli/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index b5c7c219..e3c4402a 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -96,7 +96,7 @@ func getDNSConfig() *tailcfg.DNSConfig { nameservers[index] = nameserver resolvers[index] = tailcfg.DNSResolver{ - Addr: nameserver.String() + ":53", + Addr: nameserver.String(), } } From 8735e5675cf61ea3ea4e797f5155943966b056eb Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 25 Aug 2021 19:03:04 +0100 Subject: [PATCH 30/54] Add a test for the getdnsconfig function --- cmd/headscale/cli/utils.go | 4 ++-- cmd/headscale/headscale_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index e3c4402a..aaf994d0 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -75,7 +75,7 @@ func LoadConfig(path string) error { } -func getDNSConfig() *tailcfg.DNSConfig { +func GetDNSConfig() *tailcfg.DNSConfig { if viper.IsSet("dns_config") { dnsConfig := &tailcfg.DNSConfig{} @@ -168,7 +168,7 @@ func getHeadscaleApp() (*headscale.Headscale, error) { TLSCertPath: absPath(viper.GetString("tls_cert_path")), TLSKeyPath: absPath(viper.GetString("tls_key_path")), - DNSConfig: getDNSConfig(), + DNSConfig: GetDNSConfig(), } h, err := headscale.NewHeadscale(cfg) diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index 58a0977e..58bf5899 100644 --- a/cmd/headscale/headscale_test.go +++ b/cmd/headscale/headscale_test.go @@ -95,6 +95,36 @@ func (*Suite) TestSqliteConfigLoading(c *check.C) { 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) { // Populate a custom config file configFile := filepath.Join(tmpDir, "config.yaml") From ba3dffecbfeb616660f66500ae08299d6d43be14 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 25 Aug 2021 19:05:10 +0100 Subject: [PATCH 31/54] Update readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cb42b666..fef70207 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Headscale implements this coordination server. - [X] JSON-formatted output - [X] ACLs - [X] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10) +- [X] DNS (passing DNS servers to nodes) - [ ] Share nodes between ~~users~~ namespaces -- [ ] DNS +- [ ] MagicDNS / Smart DNS ## Roadmap 🤷 From 91a48d6a435be35e82424db71686cf7d4718098d Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 26 Aug 2021 10:23:45 +0200 Subject: [PATCH 32/54] Update Dockerfile Use explicit version in Dockerfile (addresses #95) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0c2af33a..9499af22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ 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 ubuntu:latest +FROM ubuntu:20.04 COPY --from=build /go/bin/headscale /usr/local/bin/headscale ENV TZ UTC From e4ef65be761dd1b4d65e0f3ccc4c07130d28b0b1 Mon Sep 17 00:00:00 2001 From: Silver Bullet Date: Thu, 2 Sep 2021 05:44:42 +0800 Subject: [PATCH 33/54] fix: check last seen time without possible null pointer --- cmd/headscale/cli/nodes.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index d72201c9..7afc6026 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -154,8 +154,10 @@ func nodesToPtables(m []headscale.Machine) (pterm.TableData, error) { ephemeral = true } var lastSeen time.Time + var lastSeenTime string if m.LastSeen != nil { lastSeen = *m.LastSeen + lastSeenTime = lastSeen.Format("2006-01-02 15:04:05") } nKey, err := wgkey.ParseHex(m.NodeKey) if err != nil { @@ -164,12 +166,12 @@ func nodesToPtables(m []headscale.Machine) (pterm.TableData, error) { nodeKey := tailcfg.NodeKey(nKey) var online string - if m.LastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online + 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") } - d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), m.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) + d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), m.IPAddress, strconv.FormatBool(ephemeral), lastSeenTime, online}) } return d, nil } From 6faaae0c5f3aa1465b9cf6fa8145e73b72b679a2 Mon Sep 17 00:00:00 2001 From: Silver Bullet Date: Thu, 2 Sep 2021 06:08:12 +0800 Subject: [PATCH 34/54] docs: add notes on how to build own DERP server The official doc is hidden under a bunch of issues. Add a doc link here and hope it could be helpful. --- derp.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 1ecd0d7ca4a9ef453ec3f2a67349f954b8c47d54 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 16:57:26 +0200 Subject: [PATCH 35/54] Added DB SharedNode model to support sharing nodes --- db.go | 5 +++++ sharing_nodes.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 sharing_nodes.go diff --git a/db.go b/db.go index 06302523..4435f048 100644 --- a/db.go +++ b/db.go @@ -44,6 +44,11 @@ func (h *Headscale) initDB() error { return err } + err = db.AutoMigrate(&SharedNode{}) + if err != nil { + return err + } + err = h.setValue("db_version", dbVersion) return err } diff --git a/sharing_nodes.go b/sharing_nodes.go new file mode 100644 index 00000000..b52b900c --- /dev/null +++ b/sharing_nodes.go @@ -0,0 +1,37 @@ +package headscale + +import "gorm.io/gorm" + +const errorSameNamespace = Error("Destination namespace same as origin") +const errorNodeAlreadyShared = Error("Node already shared to this namespace") + +// Sharing is a join table to support sharing nodes between namespaces +type SharedNode struct { + gorm.Model + MachineID uint64 + Machine Machine + NamespaceID uint + Namespace Namespace +} + +// ShareNodeInNamespace adds a machine as a shared node to a namespace +func (h *Headscale) ShareNodeInNamespace(m *Machine, ns *Namespace) error { + if m.NamespaceID == ns.ID { + return errorSameNamespace + } + + sn := SharedNode{} + if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sn).Error; err == nil { + return errorNodeAlreadyShared + } + + sn = SharedNode{ + MachineID: m.ID, + Machine: *m, + NamespaceID: ns.ID, + Namespace: *ns, + } + h.db.Save(&sn) + + return nil +} From 48b73fa12fe69a2c1cd7a58ca32821946b049164 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 16:59:03 +0200 Subject: [PATCH 36/54] Implement node sharing functionality --- api.go | 5 +--- machine.go | 71 +++++++++++++++++++++++++++++++++++++++--------------- poll.go | 4 +-- 3 files changed, 54 insertions(+), 26 deletions(-) diff --git a/api.go b/api.go index 621eeb8f..e2a56185 100644 --- a/api.go +++ b/api.go @@ -33,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(` @@ -220,7 +218,7 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Mac Str("func", "getMapResponse"). Str("machine", req.Hostinfo.Hostname). Msg("Creating Map response") - node, err := m.toNode() + node, err := m.toNode(true) if err != nil { log.Error(). Str("func", "getMapResponse"). @@ -280,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))) diff --git a/machine.go b/machine.go index 4cdadd98..a6f8d1fb 100644 --- a/machine.go +++ b/machine.go @@ -50,7 +50,7 @@ func (m Machine) isAlreadyRegistered() bool { return m.Registered } -func (m Machine) toNode() (*tailcfg.Node, error) { +func (m Machine) toNode(includeRoutes bool) (*tailcfg.Node, error) { nKey, err := wgkey.ParseHex(m.NodeKey) if err != nil { return nil, err @@ -85,24 +85,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 _, aip := range routesStr { + ip, err := netaddr.ParseIPPrefix(aip) + if err != nil { + return nil, err + } + allowedIPs = append(allowedIPs, ip) } - allowedIPs = append(allowedIPs, ip) } endpoints := []string{} @@ -136,13 +138,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, @@ -165,6 +174,7 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) { 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 { @@ -172,9 +182,23 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) { return nil, err } + // We fetch here machines that are shared to the `Namespace` of the machine we are getting peers for + sharedNodes := []SharedNode{} + if err := h.db.Preload("Namespace").Preload("Machine").Where("namespace_id = ?", + m.NamespaceID).Find(&sharedNodes).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 _, sn := range sharedNodes { + peer, err := sn.Machine.toNode(false) // shared nodes do not expose their routes if err != nil { return nil, err } @@ -201,7 +225,7 @@ 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 @@ -260,7 +284,14 @@ func (m *Machine) GetHostInfo() (*tailcfg.Hostinfo, error) { } func (h *Headscale) notifyChangesToPeers(m *Machine) { - peers, _ := h.getPeers(*m) + 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"). diff --git a/poll.go b/poll.go index bea16164..60bfa9ea 100644 --- a/poll.go +++ b/poll.go @@ -188,7 +188,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) { Msg("Finished stream, closing PollNetMap session") } -// PollNetMapStream takes care of /machine/:id/map +// PollNetMapStream takes care of /machine/:id/map // stream logic, ensuring we communicate updates and data // to the connected clients. func (h *Headscale) PollNetMapStream( @@ -440,7 +440,7 @@ func (h *Headscale) scheduledPollWorker( 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() + n, _ := m.toNode(true) err := h.sendRequestOnUpdateChannel(n) if err != nil { log.Error(). From 7010f5afad50a161597d6a8453a7dc3eede0540b Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 16:59:12 +0200 Subject: [PATCH 37/54] Added unit tests on sharing nodes --- sharing_nodes_test.go | 359 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 sharing_nodes_test.go diff --git a/sharing_nodes_test.go b/sharing_nodes_test.go new file mode 100644 index 00000000..2c8a7a13 --- /dev/null +++ b/sharing_nodes_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.ShareNodeInNamespace(&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.ShareNodeInNamespace(&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.ShareNodeInNamespace(&m2, n1) + c.Assert(err, check.IsNil) + err = h.ShareNodeInNamespace(&m2, n1) + c.Assert(err, check.Equals, errorNodeAlreadyShared) +} + +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.ShareNodeInNamespace(&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.ShareNodeInNamespace(&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 +} From 187b016d099257ec3d6c10604703ef375b8b500a Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 16:59:50 +0200 Subject: [PATCH 38/54] Added helper function to get list of shared nodes --- namespaces.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/namespaces.go b/namespaces.go index ff9eeacc..8d9543e4 100644 --- a/namespaces.go +++ b/namespaces.go @@ -91,12 +91,30 @@ 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 } +// ListSharedMachinesInNamespaces returns all the machines that are shared to the specified namespace +func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, error) { + n, err := h.GetNamespace(name) + if err != nil { + return nil, err + } + sharedNodes := []SharedNode{} + if err := h.db.Preload("Namespace").Preload("Machine").Where(&SharedNode{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { + return nil, err + } + + machines := []Machine{} + for _, sn := range sharedNodes { + machines = append(machines, sn.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) From 4ba107a765d9a3cbec404834684db0c0bf1b9468 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 17:00:46 +0200 Subject: [PATCH 39/54] README updated --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index fef70207..712abe1a 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,12 @@ Headscale implements this coordination server. - [X] ACLs - [X] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10) - [X] DNS (passing DNS servers to nodes) -- [ ] Share nodes between ~~users~~ namespaces +- [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! From d86de68b409131ec5c77b7f8d0865cffb66d5ed1 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 17:06:47 +0200 Subject: [PATCH 40/54] Show namespace in node list table --- cmd/headscale/cli/nodes.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index d72201c9..557d93ac 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -79,6 +79,12 @@ var listNodesCmd = &cobra.Command{ if err != nil { log.Fatalf("Error initializing: %s", err) } + + ns, err := h.GetNamespace(n) + if err != nil { + log.Fatalf("Error fetching namespace: %s", err) + } + machines, err := h.ListMachinesInNamespace(n) if strings.HasPrefix(o, "json") { JsonOutput(machines, err, o) @@ -89,7 +95,7 @@ var listNodesCmd = &cobra.Command{ log.Fatalf("Error getting nodes: %s", err) } - d, err := nodesToPtables(*machines) + d, err := nodesToPtables(*ns, *machines) if err != nil { log.Fatalf("Error converting to table: %s", err) } @@ -145,8 +151,8 @@ var deleteNodeCmd = &cobra.Command{ }, } -func nodesToPtables(m []headscale.Machine) (pterm.TableData, error) { - d := pterm.TableData{{"ID", "Name", "NodeKey", "IP address", "Ephemeral", "Last seen", "Online"}} +func nodesToPtables(currNs headscale.Namespace, m []headscale.Machine) (pterm.TableData, error) { + d := pterm.TableData{{"ID", "Name", "NodeKey", "Namespace", "IP address", "Ephemeral", "Last seen", "Online"}} for _, m := range m { var ephemeral bool @@ -169,7 +175,14 @@ func nodesToPtables(m []headscale.Machine) (pterm.TableData, error) { } else { online = pterm.LightRed("false") } - d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), m.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) + + var namespace string + if currNs.ID == m.NamespaceID { + namespace = pterm.LightMagenta(m.Namespace.Name) + } else { + namespace = pterm.LightYellow(currNs.Name) + } + d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), namespace, m.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) } return d, nil } From 7287e0259ccad80cd4a5026245a445ed1a2e5380 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Thu, 2 Sep 2021 17:08:39 +0200 Subject: [PATCH 41/54] Minor linting issues --- namespaces.go | 2 +- routes.go | 2 ++ sharing_nodes.go | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/namespaces.go b/namespaces.go index 8d9543e4..fb2db01a 100644 --- a/namespaces.go +++ b/namespaces.go @@ -97,7 +97,7 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) { return &machines, nil } -// ListSharedMachinesInNamespaces returns all the machines that are shared to the specified namespace +// ListSharedMachinesInNamespace returns all the machines that are shared to the specified namespace func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, error) { n, err := h.GetNamespace(name) if err != nil { diff --git a/routes.go b/routes.go index 28d86837..0ef01780 100644 --- a/routes.go +++ b/routes.go @@ -56,6 +56,7 @@ func (h *Headscale) GetEnabledNodeRoutes(namespace string, nodeName string) ([]n 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 { @@ -129,6 +130,7 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr 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"}} diff --git a/sharing_nodes.go b/sharing_nodes.go index b52b900c..feab1fb2 100644 --- a/sharing_nodes.go +++ b/sharing_nodes.go @@ -5,7 +5,7 @@ import "gorm.io/gorm" const errorSameNamespace = Error("Destination namespace same as origin") const errorNodeAlreadyShared = Error("Node already shared to this namespace") -// Sharing is a join table to support sharing nodes between namespaces +// SharedNode is a join table to support sharing nodes between namespaces type SharedNode struct { gorm.Model MachineID uint64 From 7ce4738d8a2f0a9233f15f3f300cbc190e48c2e5 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 3 Sep 2021 10:23:26 +0200 Subject: [PATCH 42/54] Preload namespace so the name can be shown --- machine.go | 2 +- namespaces.go | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/machine.go b/machine.go index a6f8d1fb..bbacee5a 100644 --- a/machine.go +++ b/machine.go @@ -231,7 +231,7 @@ func (h *Headscale) GetMachine(namespace string, name string) (*Machine, error) // 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 diff --git a/namespaces.go b/namespaces.go index fb2db01a..57674ff3 100644 --- a/namespaces.go +++ b/namespaces.go @@ -104,13 +104,17 @@ func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, erro return nil, err } sharedNodes := []SharedNode{} - if err := h.db.Preload("Namespace").Preload("Machine").Where(&SharedNode{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { + if err := h.db.Preload("Namespace").Where(&SharedNode{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { return nil, err } machines := []Machine{} for _, sn := range sharedNodes { - machines = append(machines, sn.Machine) + m, err := h.GetMachineByID(sn.MachineID) // otherwise not everything comes filled + if err != nil { + return nil, err + } + machines = append(machines, *m) } return &machines, nil } From 7edd0cd14cd7f7e0b66a221467f70e0dde14b1f4 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 3 Sep 2021 10:23:45 +0200 Subject: [PATCH 43/54] Added add node cli --- cmd/headscale/cli/nodes.go | 72 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 557d93ac..33dd3f79 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -25,6 +25,7 @@ func init() { nodeCmd.AddCommand(listNodesCmd) nodeCmd.AddCommand(registerNodeCmd) nodeCmd.AddCommand(deleteNodeCmd) + nodeCmd.AddCommand(shareNodeCmd) } var nodeCmd = &cobra.Command{ @@ -86,8 +87,19 @@ var listNodesCmd = &cobra.Command{ } 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 } @@ -95,7 +107,7 @@ var listNodesCmd = &cobra.Command{ log.Fatalf("Error getting nodes: %s", err) } - d, err := nodesToPtables(*ns, *machines) + d, err := nodesToPtables(*ns, allMachines) if err != nil { log.Fatalf("Error converting to table: %s", err) } @@ -151,6 +163,60 @@ var deleteNodeCmd = &cobra.Command{ }, } +var shareNodeCmd = &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) { + 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) + } + + _, err = h.GetNamespace(n) + if err != nil { + log.Fatalf("Error fetching origin namespace: %s", err) + } + + destNs, 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) + } + m, err := h.GetMachineByID(uint64(id)) + if err != nil { + log.Fatalf("Error getting node: %s", err) + } + + err = h.ShareNodeInNamespace(m, destNs) + if strings.HasPrefix(o, "json") { + JsonOutput(map[string]string{"Result": "Node shared"}, err, o) + return + } + if err != nil { + fmt.Printf("Error sharing node: %s\n", err) + return + } + + fmt.Println("Node shared!") + }, +} + func nodesToPtables(currNs headscale.Namespace, m []headscale.Machine) (pterm.TableData, error) { d := pterm.TableData{{"ID", "Name", "NodeKey", "Namespace", "IP address", "Ephemeral", "Last seen", "Online"}} @@ -180,7 +246,7 @@ func nodesToPtables(currNs headscale.Namespace, m []headscale.Machine) (pterm.Ta if currNs.ID == m.NamespaceID { namespace = pterm.LightMagenta(m.Namespace.Name) } else { - namespace = pterm.LightYellow(currNs.Name) + namespace = pterm.LightYellow(m.Namespace.Name) } d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), namespace, m.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) } From 729cd54401dc122cb19b72eb868f9eee98dd999c Mon Sep 17 00:00:00 2001 From: Juan Font Date: Mon, 6 Sep 2021 14:39:52 +0200 Subject: [PATCH 44/54] Renamed sharing function --- cmd/headscale/cli/nodes.go | 2 +- sharing_nodes.go | 4 ++-- sharing_nodes_test.go | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 33dd3f79..171e98d1 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -203,7 +203,7 @@ var shareNodeCmd = &cobra.Command{ log.Fatalf("Error getting node: %s", err) } - err = h.ShareNodeInNamespace(m, destNs) + err = h.AddSharedMachineToNamespace(m, destNs) if strings.HasPrefix(o, "json") { JsonOutput(map[string]string{"Result": "Node shared"}, err, o) return diff --git a/sharing_nodes.go b/sharing_nodes.go index feab1fb2..54d9976b 100644 --- a/sharing_nodes.go +++ b/sharing_nodes.go @@ -14,8 +14,8 @@ type SharedNode struct { Namespace Namespace } -// ShareNodeInNamespace adds a machine as a shared node to a namespace -func (h *Headscale) ShareNodeInNamespace(m *Machine, ns *Namespace) error { +// 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 } diff --git a/sharing_nodes_test.go b/sharing_nodes_test.go index 2c8a7a13..7c3ff828 100644 --- a/sharing_nodes_test.go +++ b/sharing_nodes_test.go @@ -59,7 +59,7 @@ func (s *Suite) TestBasicSharedNodesInNamespace(c *check.C) { c.Assert(err, check.IsNil) c.Assert(len(*p1s), check.Equals, 0) - err = h.ShareNodeInNamespace(&m2, n1) + err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.IsNil) p1sAfter, err := h.getPeers(m1) @@ -122,7 +122,7 @@ func (s *Suite) TestSameNamespace(c *check.C) { c.Assert(err, check.IsNil) c.Assert(len(*p1s), check.Equals, 0) - err = h.ShareNodeInNamespace(&m1, n1) + err = h.AddSharedMachineToNamespace(&m1, n1) c.Assert(err, check.Equals, errorSameNamespace) } @@ -180,9 +180,9 @@ func (s *Suite) TestAlreadyShared(c *check.C) { c.Assert(err, check.IsNil) c.Assert(len(*p1s), check.Equals, 0) - err = h.ShareNodeInNamespace(&m2, n1) + err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.IsNil) - err = h.ShareNodeInNamespace(&m2, n1) + err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.Equals, errorNodeAlreadyShared) } @@ -240,7 +240,7 @@ func (s *Suite) TestDoNotIncludeRoutesOnShared(c *check.C) { c.Assert(err, check.IsNil) c.Assert(len(*p1s), check.Equals, 0) - err = h.ShareNodeInNamespace(&m2, n1) + err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.IsNil) p1sAfter, err := h.getPeers(m1) @@ -346,7 +346,7 @@ func (s *Suite) TestComplexSharingAcrossNamespaces(c *check.C) { c.Assert(err, check.IsNil) c.Assert(len(*p1s), check.Equals, 1) // nodes 1 and 4 - err = h.ShareNodeInNamespace(&m2, n1) + err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.IsNil) p1sAfter, err := h.getPeers(m1) From 75a342f96eda8ea0ebc12cda39a495dca219d2ab Mon Sep 17 00:00:00 2001 From: Juan Font Date: Mon, 6 Sep 2021 14:40:37 +0200 Subject: [PATCH 45/54] Renamed files --- sharing_nodes.go => sharing.go | 0 sharing_nodes_test.go => sharing_test.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename sharing_nodes.go => sharing.go (100%) rename sharing_nodes_test.go => sharing_test.go (100%) diff --git a/sharing_nodes.go b/sharing.go similarity index 100% rename from sharing_nodes.go rename to sharing.go diff --git a/sharing_nodes_test.go b/sharing_test.go similarity index 100% rename from sharing_nodes_test.go rename to sharing_test.go From 2780623076ba92647565e40b954fb3fb41c26956 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Mon, 6 Sep 2021 14:43:43 +0200 Subject: [PATCH 46/54] Renamed SharedNode to SharedMachine --- db.go | 2 +- machine.go | 2 +- namespaces.go | 4 ++-- sharing.go | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/db.go b/db.go index 4435f048..42c5eee9 100644 --- a/db.go +++ b/db.go @@ -44,7 +44,7 @@ func (h *Headscale) initDB() error { return err } - err = db.AutoMigrate(&SharedNode{}) + err = db.AutoMigrate(&SharedMachine{}) if err != nil { return err } diff --git a/machine.go b/machine.go index bbacee5a..c5a8a2a9 100644 --- a/machine.go +++ b/machine.go @@ -183,7 +183,7 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) { } // We fetch here machines that are shared to the `Namespace` of the machine we are getting peers for - sharedNodes := []SharedNode{} + sharedNodes := []SharedMachine{} if err := h.db.Preload("Namespace").Preload("Machine").Where("namespace_id = ?", m.NamespaceID).Find(&sharedNodes).Error; err != nil { return nil, err diff --git a/namespaces.go b/namespaces.go index 57674ff3..e7d207b2 100644 --- a/namespaces.go +++ b/namespaces.go @@ -103,8 +103,8 @@ func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, erro if err != nil { return nil, err } - sharedNodes := []SharedNode{} - if err := h.db.Preload("Namespace").Where(&SharedNode{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { + sharedNodes := []SharedMachine{} + if err := h.db.Preload("Namespace").Where(&SharedMachine{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { return nil, err } diff --git a/sharing.go b/sharing.go index 54d9976b..db16ef33 100644 --- a/sharing.go +++ b/sharing.go @@ -5,8 +5,8 @@ import "gorm.io/gorm" const errorSameNamespace = Error("Destination namespace same as origin") const errorNodeAlreadyShared = Error("Node already shared to this namespace") -// SharedNode is a join table to support sharing nodes between namespaces -type SharedNode struct { +// SharedMachine is a join table to support sharing nodes between namespaces +type SharedMachine struct { gorm.Model MachineID uint64 Machine Machine @@ -20,12 +20,12 @@ func (h *Headscale) AddSharedMachineToNamespace(m *Machine, ns *Namespace) error return errorSameNamespace } - sn := SharedNode{} + sn := SharedMachine{} if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sn).Error; err == nil { return errorNodeAlreadyShared } - sn = SharedNode{ + sn = SharedMachine{ MachineID: m.ID, Machine: *m, NamespaceID: ns.ID, From 55f3e07bd45bbe8e7d84004fee9c17c857fb8aa8 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:26:46 +0200 Subject: [PATCH 47/54] Apply suggestions from code review Removed one letter variables Co-authored-by: Kristoffer Dalby --- cmd/headscale/cli/nodes.go | 32 ++++++++++++++++---------------- machine.go | 12 ++++++------ namespaces.go | 12 ++++++------ sharing.go | 8 ++++---- sharing_test.go | 2 +- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 171e98d1..623f7f8c 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -25,7 +25,7 @@ func init() { nodeCmd.AddCommand(listNodesCmd) nodeCmd.AddCommand(registerNodeCmd) nodeCmd.AddCommand(deleteNodeCmd) - nodeCmd.AddCommand(shareNodeCmd) + nodeCmd.AddCommand(shareMachineCmd) } var nodeCmd = &cobra.Command{ @@ -81,7 +81,7 @@ var listNodesCmd = &cobra.Command{ log.Fatalf("Error initializing: %s", err) } - ns, err := h.GetNamespace(n) + namespace, err := h.GetNamespace(n) if err != nil { log.Fatalf("Error fetching namespace: %s", err) } @@ -107,7 +107,7 @@ var listNodesCmd = &cobra.Command{ log.Fatalf("Error getting nodes: %s", err) } - d, err := nodesToPtables(*ns, allMachines) + d, err := nodesToPtables(*namespace, allMachines) if err != nil { log.Fatalf("Error converting to table: %s", err) } @@ -163,7 +163,7 @@ var deleteNodeCmd = &cobra.Command{ }, } -var shareNodeCmd = &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 { @@ -173,23 +173,23 @@ var shareNodeCmd = &cobra.Command{ return nil }, Run: func(cmd *cobra.Command, args []string) { - n, err := cmd.Flags().GetString("namespace") + namespace, err := cmd.Flags().GetString("namespace") if err != nil { log.Fatalf("Error getting namespace: %s", err) } - o, _ := cmd.Flags().GetString("output") + output, _ := cmd.Flags().GetString("output") h, err := getHeadscaleApp() if err != nil { log.Fatalf("Error initializing: %s", err) } - _, err = h.GetNamespace(n) + _, err = h.GetNamespace(namespace) if err != nil { log.Fatalf("Error fetching origin namespace: %s", err) } - destNs, err := h.GetNamespace(args[1]) + destinationNamespace, err := h.GetNamespace(args[1]) if err != nil { log.Fatalf("Error fetching destination namespace: %s", err) } @@ -198,12 +198,12 @@ var shareNodeCmd = &cobra.Command{ if err != nil { log.Fatalf("Error converting ID to integer: %s", err) } - m, err := h.GetMachineByID(uint64(id)) + machine, err := h.GetMachineByID(uint64(id)) if err != nil { log.Fatalf("Error getting node: %s", err) } - err = h.AddSharedMachineToNamespace(m, destNs) + err = h.AddSharedMachineToNamespace(machine, destinationNamespace) if strings.HasPrefix(o, "json") { JsonOutput(map[string]string{"Result": "Node shared"}, err, o) return @@ -217,10 +217,10 @@ var shareNodeCmd = &cobra.Command{ }, } -func nodesToPtables(currNs headscale.Namespace, m []headscale.Machine) (pterm.TableData, error) { +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 _, m := range m { + for _, machine := range machines { var ephemeral bool if m.AuthKey != nil && m.AuthKey.Ephemeral { ephemeral = true @@ -243,12 +243,12 @@ func nodesToPtables(currNs headscale.Namespace, m []headscale.Machine) (pterm.Ta } var namespace string - if currNs.ID == m.NamespaceID { - namespace = pterm.LightMagenta(m.Namespace.Name) + if currentNamespace.ID == machine.NamespaceID { + namespace = pterm.LightMagenta(machine.Namespace.Name) } else { - namespace = pterm.LightYellow(m.Namespace.Name) + namespace = pterm.LightYellow(machine.Namespace.Name) } - d = append(d, []string{strconv.FormatUint(m.ID, 10), m.Name, nodeKey.ShortString(), namespace, m.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) + d = append(d, []string{strconv.FormatUint(machine.ID, 10), machine.Name, nodeKey.ShortString(), namespace, machine.IPAddress, strconv.FormatBool(ephemeral), lastSeen.Format("2006-01-02 15:04:05"), online}) } return d, nil } diff --git a/machine.go b/machine.go index c5a8a2a9..40fabee6 100644 --- a/machine.go +++ b/machine.go @@ -98,8 +98,8 @@ func (m Machine) toNode(includeRoutes bool) (*tailcfg.Node, error) { } } - for _, aip := range routesStr { - ip, err := netaddr.ParseIPPrefix(aip) + for _, routeStr := range routesStr { + ip, err := netaddr.ParseIPPrefix(routeStr) if err != nil { return nil, err } @@ -183,9 +183,9 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) { } // We fetch here machines that are shared to the `Namespace` of the machine we are getting peers for - sharedNodes := []SharedMachine{} + sharedMachines := []SharedMachine{} if err := h.db.Preload("Namespace").Preload("Machine").Where("namespace_id = ?", - m.NamespaceID).Find(&sharedNodes).Error; err != nil { + m.NamespaceID).Find(&sharedMachines).Error; err != nil { return nil, err } @@ -197,8 +197,8 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) { } peers = append(peers, peer) } - for _, sn := range sharedNodes { - peer, err := sn.Machine.toNode(false) // shared nodes do not expose their routes + for _, sharedMachine := range sharedMachines { + peer, err := sharedMachine.Machine.toNode(false) // shared nodes do not expose their routes if err != nil { return nil, err } diff --git a/namespaces.go b/namespaces.go index e7d207b2..8204f96d 100644 --- a/namespaces.go +++ b/namespaces.go @@ -99,22 +99,22 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) { // ListSharedMachinesInNamespace returns all the machines that are shared to the specified namespace func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, error) { - n, err := h.GetNamespace(name) + namespace, err := h.GetNamespace(name) if err != nil { return nil, err } - sharedNodes := []SharedMachine{} - if err := h.db.Preload("Namespace").Where(&SharedMachine{NamespaceID: n.ID}).Find(&sharedNodes).Error; err != nil { + sharedMachines := []SharedMachine{} + if err := h.db.Preload("Namespace").Where(&SharedMachine{NamespaceID: namespace.ID}).Find(&sharedMachines).Error; err != nil { return nil, err } machines := []Machine{} - for _, sn := range sharedNodes { - m, err := h.GetMachineByID(sn.MachineID) // otherwise not everything comes filled + for _, sharedMachine := range sharedMachines { + machine, err := h.GetMachineByID(sharedMachine.MachineID) // otherwise not everything comes filled if err != nil { return nil, err } - machines = append(machines, *m) + machines = append(machines, *machine) } return &machines, nil } diff --git a/sharing.go b/sharing.go index db16ef33..c507707a 100644 --- a/sharing.go +++ b/sharing.go @@ -21,17 +21,17 @@ func (h *Headscale) AddSharedMachineToNamespace(m *Machine, ns *Namespace) error } sn := SharedMachine{} - if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sn).Error; err == nil { - return errorNodeAlreadyShared + if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sharedMachine).Error; err == nil { + return errorMachineAlreadyShared } - sn = SharedMachine{ + sharedMachine = SharedMachine{ MachineID: m.ID, Machine: *m, NamespaceID: ns.ID, Namespace: *ns, } - h.db.Save(&sn) + h.db.Save(&sharedMachine) return nil } diff --git a/sharing_test.go b/sharing_test.go index 7c3ff828..ec4951de 100644 --- a/sharing_test.go +++ b/sharing_test.go @@ -183,7 +183,7 @@ func (s *Suite) TestAlreadyShared(c *check.C) { err = h.AddSharedMachineToNamespace(&m2, n1) c.Assert(err, check.IsNil) err = h.AddSharedMachineToNamespace(&m2, n1) - c.Assert(err, check.Equals, errorNodeAlreadyShared) + c.Assert(err, check.Equals, errorMachineAlreadyShared) } func (s *Suite) TestDoNotIncludeRoutesOnShared(c *check.C) { From b937f9b7629ab1c40277fb04a773774d5355e606 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:30:02 +0200 Subject: [PATCH 48/54] Update machine.go Added comment on toNode --- machine.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/machine.go b/machine.go index 40fabee6..3e9786a2 100644 --- a/machine.go +++ b/machine.go @@ -50,6 +50,8 @@ func (m Machine) isAlreadyRegistered() bool { return m.Registered } +// 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 { From b098d84557318d30e6bb1aa907c82a4d55d6a6bb Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:32:06 +0200 Subject: [PATCH 49/54] Apply suggestions from code review Changed more variable names Co-authored-by: Kristoffer Dalby --- sharing.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sharing.go b/sharing.go index c507707a..98811e5d 100644 --- a/sharing.go +++ b/sharing.go @@ -3,7 +3,7 @@ package headscale import "gorm.io/gorm" const errorSameNamespace = Error("Destination namespace same as origin") -const errorNodeAlreadyShared = Error("Node already shared to this namespace") +const errorMachineAlreadyShared = Error("Node already shared to this namespace") // SharedMachine is a join table to support sharing nodes between namespaces type SharedMachine struct { From 4b4a5a4b93feeb7404c853ac45e97ad2a208c123 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:32:42 +0200 Subject: [PATCH 50/54] Update sharing.go Co-authored-by: Kristoffer Dalby --- sharing.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sharing.go b/sharing.go index 98811e5d..93c299c7 100644 --- a/sharing.go +++ b/sharing.go @@ -20,7 +20,7 @@ func (h *Headscale) AddSharedMachineToNamespace(m *Machine, ns *Namespace) error return errorSameNamespace } - sn := SharedMachine{} + sharedMachine := SharedMachine{} if err := h.db.Where("machine_id = ? AND namespace_id", m.ID, ns.ID).First(&sharedMachine).Error; err == nil { return errorMachineAlreadyShared } From bd6adfaec6ff9bd199dc26314cc43812a7018830 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:37:01 +0200 Subject: [PATCH 51/54] Changes a few more variables --- cmd/headscale/cli/nodes.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 623f7f8c..07d21f33 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -204,8 +204,8 @@ var shareMachineCmd = &cobra.Command{ } err = h.AddSharedMachineToNamespace(machine, destinationNamespace) - if strings.HasPrefix(o, "json") { - JsonOutput(map[string]string{"Result": "Node shared"}, err, o) + if strings.HasPrefix(output, "json") { + JsonOutput(map[string]string{"Result": "Node shared"}, err, output) return } if err != nil { @@ -222,21 +222,21 @@ func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.M for _, machine := range machines { var ephemeral bool - if m.AuthKey != nil && m.AuthKey.Ephemeral { + if machine.AuthKey != nil && machine.AuthKey.Ephemeral { ephemeral = true } var lastSeen time.Time - if m.LastSeen != nil { - lastSeen = *m.LastSeen + if machine.LastSeen != nil { + lastSeen = *machine.LastSeen } - nKey, err := wgkey.ParseHex(m.NodeKey) + nKey, err := wgkey.ParseHex(machine.NodeKey) if err != nil { return nil, err } nodeKey := tailcfg.NodeKey(nKey) var online string - if m.LastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online + if machine.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") From 8acaea0fbe8a683d380c430f286eeb6073dd1395 Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:44:27 +0200 Subject: [PATCH 52/54] Increased timeout --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 98dbc464..0961297a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: # golangci-lint manually in the `Run lint` step. - uses: golangci/golangci-lint-action@v2 with: - args: --timeout 2m + args: --timeout 4m # Setup Go - name: Setup Go From c4e6ad1ec788fab5fee7f4b3bc6355154f0a154e Mon Sep 17 00:00:00 2001 From: Juan Font Date: Fri, 10 Sep 2021 00:52:08 +0200 Subject: [PATCH 53/54] Fixed some typos --- cmd/headscale/cli/nodes.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 98dea9aa..5f30dc1b 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -228,7 +228,7 @@ func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.M var lastSeen time.Time var lastSeenTime string if machine.LastSeen != nil { - lastSeen = *m.LastSeen + lastSeen = *machine.LastSeen lastSeenTime = lastSeen.Format("2006-01-02 15:04:05") } nKey, err := wgkey.ParseHex(machine.NodeKey) @@ -239,8 +239,7 @@ func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.M var online string if lastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online - online = pter - LightGreen("true") + online = pterm.LightGreen("true") } else { online = pterm.LightRed("false") } From 11fbef4bf072b4fc696be9f5670752cc7f554b3d Mon Sep 17 00:00:00 2001 From: Juan Font Date: Sat, 11 Sep 2021 23:21:45 +0200 Subject: [PATCH 54/54] Added extra timeout --- .github/workflows/lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0961297a..d1c21f72 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,4 +36,6 @@ jobs: sudo apt install -y make - name: Run lint + with: + args: --timeout 4m run: make lint