Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Headscale implements this coordination server.
- [x] ~~Multiuser~~ Namespace support
- [x] Basic routing (advertise & accept)
- [ ] Share nodes between ~~users~~ namespaces
- [x] Node registration via pre-auth keys
- [x] Node registration via pre-auth keys (including reusable keys and ephemeral node support)
- [X] JSON-formatted output
- [ ] ACLs
- [ ] DNS
Expand Down Expand Up @@ -97,6 +97,7 @@ Alternatively, you can use Auth Keys to register your machines:
tailscale up -login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY
```

If you create an authkey with the `--ephemeral` flag, that key will create ephemeral nodes. This implies that `--reusable` is true.

Please bear in mind that all the commands from headscale support adding `-o json` or `-o json-line` to get a nicely JSON-formatted output.

Expand Down Expand Up @@ -124,6 +125,12 @@ Headscale's configuration file is named `config.json` or `config.yaml`. Headscal

`derp_map_path` is the path to the [DERP](https://pkg.go.dev/tailscale.com/derp) map file. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.

```
"ephemeral_node_inactivity_timeout": "30m",
```

`ephemeral_node_inactivity_timeout` is the timeout after which inactive ephemeral node records will be deleted from the database. The default is 30 minutes. This value must be higher than 65 seconds (the keepalive timeout for the HTTP long poll is 60 seconds, plus a few seconds to avoid race conditions).

```
"db_host": "localhost",
"db_port": 5432,
Expand Down
17 changes: 11 additions & 6 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
// We do have the updated key!
if m.NodeKey == wgcfg.Key(req.NodeKey).HexString() {
if m.Registered {
log.Println("Client is registered and we have the current key. All clear to /map")
log.Printf("[%s] Client is registered and we have the current key. All clear to /map\n", m.Name)
resp.AuthURL = ""
resp.User = *m.Namespace.toUser()
resp.MachineAuthorized = true
Expand Down Expand Up @@ -174,7 +174,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
defer db.Close()
var m Machine
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
log.Printf("Cannot fingitd machine: %s", err)
log.Printf("Ignoring request, cannot find machine with key %s", mKey.HexString())
return
}

Expand Down Expand Up @@ -243,29 +243,34 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
log.Printf("[%s] Sending data (%d bytes)", m.Name, len(data))
_, err := w.Write(data)
if err != nil {
fmt.Printf("[%s] 🤮 Cannot write data: %s", m.Name, err)
log.Printf("[%s] 🤮 Cannot write data: %s", m.Name, err)
}
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
return true

case <-update:
log.Printf("[%s] Received a request for update", m.Name)
data, err := h.getMapResponse(mKey, req, m)
if err != nil {
fmt.Printf("[%s] 🤮 Cannot get the poll response: %s", m.Name, err)
log.Printf("[%s] 🤮 Cannot get the poll response: %s", m.Name, err)
}
_, err = w.Write(*data)
if err != nil {
fmt.Printf("[%s] 🤮 Cannot write the poll response: %s", m.Name, err)
log.Printf("[%s] 🤮 Cannot write the poll response: %s", m.Name, err)
}
return true

case <-c.Request.Context().Done():
log.Printf("[%s] 😥 The client has closed the connection", m.Name)
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
h.pollMu.Lock()
cancelKeepAlive <- []byte{}
delete(h.clientsPolling, m.ID)
h.pollMu.Unlock()

return false

}
Expand Down
61 changes: 54 additions & 7 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"strings"
"sync"
"time"

"github.com/gin-gonic/gin"
"golang.org/x/crypto/acme/autocert"
Expand All @@ -17,10 +18,11 @@ import (

// Config contains the initial Headscale configuration
type Config struct {
ServerURL string
Addr string
PrivateKeyPath string
DerpMap *tailcfg.DERPMap
ServerURL string
Addr string
PrivateKeyPath string
DerpMap *tailcfg.DERPMap
EphemeralNodeInactivityTimeout time.Duration

DBtype string
DBpath string
Expand Down Expand Up @@ -95,6 +97,51 @@ func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, http.StatusFound)
}

// ExpireEphemeralNodes deletes ephemeral machine records that have not been
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout
func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
if milliSeconds == 0 {
// For testing
h.expireEphemeralNodesWorker()
return
}
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
h.expireEphemeralNodesWorker()
}
}

func (h *Headscale) expireEphemeralNodesWorker() {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return
}
defer db.Close()

namespaces, err := h.ListNamespaces()
if err != nil {
log.Printf("Error listing namespaces: %s", err)
return
}
for _, ns := range *namespaces {
machines, err := h.ListMachinesInNamespace(ns.Name)
if err != nil {
log.Printf("Error listing machines in namespace %s: %s", ns.Name, err)
return
}
for _, m := range *machines {
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
log.Printf("[%s] Ephemeral client removed from database\n", m.Name)
err = db.Unscoped().Delete(m).Error
if err != nil {
log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err)
}
}
}
}
}

// Serve launches a GIN server with the Headscale API
func (h *Headscale) Serve() error {
r := gin.Default()
Expand All @@ -105,7 +152,7 @@ func (h *Headscale) Serve() error {
var err error
if h.cfg.TLSLetsEncryptHostname != "" {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
fmt.Println("WARNING: listening with TLS but ServerURL does not start with https://")
log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
}

m := autocert.Manager{
Expand Down Expand Up @@ -136,12 +183,12 @@ func (h *Headscale) Serve() error {
}
} else if h.cfg.TLSCertPath == "" {
if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
fmt.Println("WARNING: listening without TLS but ServerURL does not start with http://")
log.Println("WARNING: listening without TLS but ServerURL does not start with http://")
}
err = r.Run(h.cfg.Addr)
} else {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
fmt.Println("WARNING: listening with TLS but ServerURL does not start with https://")
log.Println("WARNING: listening with TLS but ServerURL does not start with https://")
}
err = r.RunTLS(h.cfg.Addr, h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/headscale/cli/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ var ListNodesCmd = &cobra.Command{
log.Fatalf("Error getting nodes: %s", err)
}

fmt.Printf("name\t\tlast seen\n")
fmt.Printf("name\t\tlast seen\t\tephemeral\n")
for _, m := range *machines {
fmt.Printf("%s\t%s\n", m.Name, m.LastSeen.Format("2006-01-02 15:04:05"))
fmt.Printf("%s\t%s\t%t\n", m.Name, m.LastSeen.Format("2006-01-02 15:04:05"), m.AuthKey.Ephemeral)
}

},
Expand Down
6 changes: 4 additions & 2 deletions cmd/headscale/cli/preauthkeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ var ListPreAuthKeys = &cobra.Command{
expiration = k.Expiration.Format("2006-01-02 15:04:05")
}
fmt.Printf(
"key: %s, namespace: %s, reusable: %v, expiration: %s, created_at: %s\n",
"key: %s, namespace: %s, reusable: %v, ephemeral: %v, expiration: %s, created_at: %s\n",
k.Key,
k.Namespace.Name,
k.Reusable,
k.Ephemeral,
expiration,
k.CreatedAt.Format("2006-01-02 15:04:05"),
)
Expand All @@ -71,6 +72,7 @@ var CreatePreAuthKeyCmd = &cobra.Command{
log.Fatalf("Error initializing: %s", err)
}
reusable, _ := cmd.Flags().GetBool("reusable")
ephemeral, _ := cmd.Flags().GetBool("ephemeral")

e, _ := cmd.Flags().GetString("expiration")
var expiration *time.Time
Expand All @@ -83,7 +85,7 @@ var CreatePreAuthKeyCmd = &cobra.Command{
expiration = &exp
}

k, err := h.CreatePreAuthKey(n, reusable, expiration)
k, err := h.CreatePreAuthKey(n, reusable, ephemeral, expiration)
if strings.HasPrefix(o, "json") {
JsonOutput(k, err, o)
return
Expand Down
1 change: 1 addition & 0 deletions cmd/headscale/cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var ServeCmd = &cobra.Command{
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
go h.ExpireEphemeralNodes(5000)
err = h.Serve()
if err != nil {
log.Fatalf("Error initializing: %s", err)
Expand Down
11 changes: 11 additions & 0 deletions cmd/headscale/cli/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/juanfont/headscale"
"github.com/spf13/viper"
Expand Down Expand Up @@ -37,12 +38,22 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
log.Printf("Could not load DERP servers map file: %s", err)
}

// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
// to avoid races
minInactivityTimeout, _ := time.ParseDuration("65s")
if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout {
err = fmt.Errorf("ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s\n", viper.GetString("ephemeral_node_inactivity_timeout"), minInactivityTimeout)
return nil, err
}

cfg := headscale.Config{
ServerURL: viper.GetString("server_url"),
Addr: viper.GetString("listen_addr"),
PrivateKeyPath: absPath(viper.GetString("private_key_path")),
DerpMap: derpMap,

EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"),

DBtype: viper.GetString("db_type"),
DBpath: absPath(viper.GetString("db_path")),
DBhost: viper.GetString("db_host"),
Expand Down
1 change: 1 addition & 0 deletions cmd/headscale/headscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func main() {
cli.PreauthkeysCmd.AddCommand(cli.CreatePreAuthKeyCmd)

cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
cli.CreatePreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")

headscaleCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
Expand Down
1 change: 1 addition & 0 deletions config.json.postgres.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"listen_addr": "0.0.0.0:8000",
"private_key_path": "private.key",
"derp_map_path": "derp.yaml",
"ephemeral_node_inactivity_timeout": "30m",
"db_type": "postgres",
"db_host": "localhost",
"db_port": 5432,
Expand Down
1 change: 1 addition & 0 deletions config.json.sqlite.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"listen_addr": "0.0.0.0:8000",
"private_key_path": "private.key",
"derp_map_path": "derp.yaml",
"ephemeral_node_inactivity_timeout": "30m",
"db_type": "sqlite3",
"db_path": "db.sqlite",
"tls_letsencrypt_hostname": "",
Expand Down
2 changes: 1 addition & 1 deletion machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func (m Machine) toNode() (*tailcfg.Node, error) {

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 permantent
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),
Expand Down
4 changes: 1 addition & 3 deletions machine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import (
"gopkg.in/check.v1"
)

var _ = check.Suite(&Suite{})

func (s *Suite) TestGetMachine(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)

pak, err := h.CreatePreAuthKey(n.Name, false, nil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)

db, err := h.db()
Expand Down
2 changes: 1 addition & 1 deletion namespaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
defer db.Close()

machines := []Machine{}
if err := db.Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
if err := db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
return nil, err
}
return &machines, nil
Expand Down
4 changes: 1 addition & 3 deletions namespaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"gopkg.in/check.v1"
)

var _ = check.Suite(&Suite{})

func (s *Suite) TestCreateAndDestroyNamespace(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
Expand All @@ -29,7 +27,7 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)

pak, err := h.CreatePreAuthKey(n.Name, false, nil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)

db, err := h.db()
Expand Down
6 changes: 4 additions & 2 deletions preauth_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ type PreAuthKey struct {
NamespaceID uint
Namespace Namespace
Reusable bool
Ephemeral bool `gorm:"default:false"`

CreatedAt *time.Time
Expiration *time.Time
}

// CreatePreAuthKey creates a new PreAuthKey in a namespace, and returns it
func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, expiration *time.Time) (*PreAuthKey, error) {
func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, ephemeral bool, expiration *time.Time) (*PreAuthKey, error) {
n, err := h.GetNamespace(namespaceName)
if err != nil {
return nil, err
Expand All @@ -48,6 +49,7 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, expira
NamespaceID: n.ID,
Namespace: *n,
Reusable: reusable,
Ephemeral: ephemeral,
CreatedAt: &now,
Expiration: expiration,
}
Expand Down Expand Up @@ -94,7 +96,7 @@ func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
return nil, errorAuthKeyExpired
}

if pak.Reusable { // we don't need to check if has been used before
if pak.Reusable || pak.Ephemeral { // we don't need to check if has been used before
return &pak, nil
}

Expand Down
Loading