Merge pull request #22 from juanfont/json-output

Added JSON-formatted output to CLI
This commit is contained in:
Juan Font 2021-05-08 19:55:19 +02:00 committed by GitHub
commit 3cf599be64
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 167 additions and 47 deletions

View file

@ -23,6 +23,7 @@ Headscale implements this coordination server.
- [x] Basic routing (advertise & accept)
- [ ] Share nodes between ~~users~~ namespaces
- [x] Node registration via pre-auth keys
- [X] JSON-formatted output
- [ ] ACLs
- [ ] DNS
@ -79,6 +80,22 @@ Suggestions/PRs welcomed!
./headscale -n myfirstnamespace node register YOURMACHINEKEY
```
Alternatively, you can use Auth Keys to register your machines:
1. Create an authkey
```shell
./headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
```
2. Use the authkey from your machine to register it
```shell
tailscale up -login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY
```
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.
## Configuration reference
Headscale's configuration file is named `config.json` or `config.yaml`. Headscale will look for it in `/etc/headscale`, `~/.headscale` and finally the directory from where the Headscale binary is executed.
@ -131,7 +148,15 @@ To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/)
## Disclaimer
1. I have nothing to do with Tailscale, or Tailscale Inc.
1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of writing this was to learn how Tailscale works.
3. I don't use Headscale myself.
3. ~~I don't use Headscale myself.~~
## More on Tailscale
- https://tailscale.com/blog/how-tailscale-works/
- https://tailscale.com/blog/tailscale-key-management/
- https://tailscale.com/blog/an-unlikely-database-migration/

23
cli.go
View file

@ -1,50 +1,45 @@
package headscale
import (
"fmt"
"errors"
"log"
"tailscale.com/wgengine/wgcfg"
)
// RegisterMachine is executed from the CLI to register a new Machine using its MachineKey
func (h *Headscale) RegisterMachine(key string, namespace string) error {
func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, error) {
ns, err := h.GetNamespace(namespace)
if err != nil {
return err
return nil, err
}
mKey, err := wgcfg.ParseHexKey(key)
if err != nil {
log.Printf("Cannot parse client key: %s", err)
return err
return nil, err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return err
return nil, err
}
defer db.Close()
m := Machine{}
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
log.Printf("Cannot find machine with machine key: %s", mKey.Base64())
return err
return nil, errors.New("Machine not found")
}
if m.isAlreadyRegistered() {
fmt.Println("This machine already registered")
return nil
return nil, errors.New("Machine already registered")
}
ip, err := h.getAvailableIP()
if err != nil {
log.Println(err)
return err
return nil, err
}
m.IPAddress = ip.String()
m.NamespaceID = ns.ID
m.Registered = true
m.RegisterMethod = "cli"
db.Save(&m)
fmt.Println("Machine registered 🎉")
return nil
return &m, nil
}

View file

@ -3,6 +3,7 @@ package cli
import (
"fmt"
"log"
"strings"
"github.com/spf13/cobra"
)
@ -22,16 +23,21 @@ var CreateNamespaceCmd = &cobra.Command{
return nil
},
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
_, err = h.CreateNamespace(args[0])
if err != nil {
fmt.Println(err)
namespace, err := h.CreateNamespace(args[0])
if strings.HasPrefix(o, "json") {
JsonOutput(namespace, err, o)
return
}
fmt.Printf("Ook.\n")
if err != nil {
fmt.Printf("Error creating namespace: %s\n", err)
return
}
fmt.Printf("Namespace created\n")
},
}
@ -39,17 +45,22 @@ var ListNamespacesCmd = &cobra.Command{
Use: "list",
Short: "List all the namespaces",
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
ns, err := h.ListNamespaces()
namespaces, err := h.ListNamespaces()
if strings.HasPrefix(o, "json") {
JsonOutput(namespaces, err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("ID\tName\n")
for _, n := range *ns {
for _, n := range *namespaces {
fmt.Printf("%d\t%s\n", n.ID, n.Name)
}
},

View file

@ -3,6 +3,7 @@ package cli
import (
"fmt"
"log"
"strings"
"github.com/spf13/cobra"
)
@ -21,17 +22,22 @@ var RegisterCmd = &cobra.Command{
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.RegisterMachine(args[0], n)
if err != nil {
fmt.Printf("Error: %s", err)
m, err := h.RegisterMachine(args[0], n)
if strings.HasPrefix(o, "json") {
JsonOutput(m, err, o)
return
}
fmt.Println("Ook.")
if err != nil {
fmt.Printf("Cannot register machine: %s\n", err)
return
}
fmt.Printf("Machine registered\n")
},
}
@ -43,12 +49,18 @@ var ListNodesCmd = &cobra.Command{
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)
}
machines, err := h.ListMachinesInNamespace(n)
if strings.HasPrefix(o, "json") {
JsonOutput(machines, err, o)
return
}
if err != nil {
log.Fatalf("Error getting nodes: %s", err)
}

View file

@ -3,6 +3,7 @@ package cli
import (
"fmt"
"log"
"strings"
"time"
"github.com/hako/durafmt"
@ -22,14 +23,20 @@ var ListPreAuthKeys = &cobra.Command{
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)
}
keys, err := h.GetPreAuthKeys(n)
if strings.HasPrefix(o, "json") {
JsonOutput(keys, err, o)
return
}
if err != nil {
fmt.Println(err)
fmt.Printf("Error getting the list of keys: %s\n", err)
return
}
for _, k := range *keys {
@ -57,6 +64,7 @@ var CreatePreAuthKeyCmd = &cobra.Command{
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
o, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
@ -75,11 +83,15 @@ var CreatePreAuthKeyCmd = &cobra.Command{
expiration = &exp
}
_, err = h.CreatePreAuthKey(n, reusable, expiration)
k, err := h.CreatePreAuthKey(n, reusable, expiration)
if strings.HasPrefix(o, "json") {
JsonOutput(k, err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("Ook.\n")
fmt.Printf("Key: %s\n", k.Key)
},
}

View file

@ -3,6 +3,7 @@ package cli
import (
"fmt"
"log"
"strings"
"github.com/spf13/cobra"
)
@ -26,16 +27,24 @@ var ListRoutesCmd = &cobra.Command{
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)
}
routes, err := h.GetNodeRoutes(n, args[0])
if strings.HasPrefix(o, "json") {
JsonOutput(routes, err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Println(routes)
},
}
@ -54,15 +63,22 @@ var EnableRouteCmd = &cobra.Command{
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.EnableNodeRoute(n, args[0], args[1])
route, err := h.EnableNodeRoute(n, args[0], args[1])
if strings.HasPrefix(o, "json") {
JsonOutput(route, err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("Enabled route %s\n", route)
},
}

View file

@ -1,6 +1,8 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
@ -13,6 +15,10 @@ import (
"tailscale.com/tailcfg"
)
type ErrorOutput struct {
Error string
}
func absPath(path string) string {
// If a relative path is provided, prefix it with the the directory where
// the config file was found.
@ -72,3 +78,35 @@ func loadDerpMap(path string) (*tailcfg.DERPMap, error) {
err = yaml.Unmarshal(b, &derpMap)
return &derpMap, err
}
func JsonOutput(result interface{}, errResult error, outputFormat string) {
var j []byte
var err error
switch outputFormat {
case "json":
if errResult != nil {
j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t")
if err != nil {
log.Fatalln(err)
}
} else {
j, err = json.MarshalIndent(result, "", "\t")
if err != nil {
log.Fatalln(err)
}
}
case "json-line":
if errResult != nil {
j, err = json.Marshal(ErrorOutput{errResult.Error()})
if err != nil {
log.Fatalln(err)
}
} else {
j, err = json.Marshal(result)
if err != nil {
log.Fatalln(err)
}
}
}
fmt.Println(string(j))
}

View file

@ -19,6 +19,11 @@ var versionCmd = &cobra.Command{
Short: "Print the version.",
Long: "The version of headscale.",
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
if strings.HasPrefix(o, "json") {
cli.JsonOutput(map[string]string{"version": version}, nil, o)
return
}
fmt.Println(version)
},
}
@ -123,6 +128,8 @@ func main() {
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
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'")
if err := headscaleCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(-1)

View file

@ -26,18 +26,18 @@ func (h *Headscale) GetNodeRoutes(namespace string, nodeName string) (*[]netaddr
// 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) error {
func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr string) (*netaddr.IPPrefix, error) {
m, err := h.GetMachine(namespace, nodeName)
if err != nil {
return err
return nil, err
}
hi, err := m.GetHostInfo()
if err != nil {
return err
return nil, err
}
route, err := netaddr.ParseIPPrefix(routeStr)
if err != nil {
return err
return nil, err
}
for _, rIP := range hi.RoutableIPs {
@ -45,7 +45,7 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return err
return nil, err
}
routes, _ := json.Marshal([]string{routeStr}) // TODO: only one for the time being, so overwriting the rest
@ -53,6 +53,10 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
db.Save(&m)
db.Close()
// THIS IS COMPLETELY USELESS.
// The peers map is stored in memory in the server process.
// Definetely not accessible from the CLI tool.
// We need RPC to the server - or some kind of 'needsUpdate' field in the DB
peers, _ := h.getPeers(*m)
h.pollMu.Lock()
for _, p := range *peers {
@ -61,9 +65,9 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
}
}
h.pollMu.Unlock()
return nil
return &rIP, nil
}
}
return errors.New("could not find routable range")
return nil, errors.New("could not find routable range")
}

View file

@ -105,8 +105,8 @@ func getRandomIP() (*net.IP, error) {
ipo, ipnet, err := net.ParseCIDR("100.64.0.0/10")
if err == nil {
ip := ipo.To4()
fmt.Println("In Randomize IPAddr: IP ", ip, " IPNET: ", ipnet)
fmt.Println("Final address is ", ip)
// fmt.Println("In Randomize IPAddr: IP ", ip, " IPNET: ", ipnet)
// fmt.Println("Final address is ", ip)
// fmt.Println("Broadcast address is ", ipb)
// fmt.Println("Network address is ", ipn)
r := mathrand.Uint32()
@ -119,7 +119,7 @@ func getRandomIP() (*net.IP, error) {
ip[i] = ip[i] + (v &^ ipnet.Mask[i])
// fmt.Println("IP After: ", ip[i])
}
fmt.Println("FINAL IP: ", ip.String())
// fmt.Println("FINAL IP: ", ip.String())
return &ip, nil
}