Browse Source

cmd/tailscale/cli, client/tailscale, ipn/localapi: add tailscale syspolicy {list,reload} commands

In this PR, we add the tailscale syspolicy command with two subcommands: list, which displays
policy settings, and reload, which forces a reload of those settings. We also update the LocalAPI
and LocalClient to facilitate these additions.

Updates #12687

Signed-off-by: Nick Khyl <[email protected]>
Nick Khyl 1 year ago
parent
commit
3f626c0d77

+ 28 - 0
client/tailscale/localclient.go

@@ -40,6 +40,7 @@ import (
 	"tailscale.com/types/dnstype"
 	"tailscale.com/types/key"
 	"tailscale.com/types/tkatype"
+	"tailscale.com/util/syspolicy/setting"
 )
 
 // defaultLocalClient is the default LocalClient when using the legacy
@@ -814,6 +815,33 @@ func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn
 	return decodeJSON[*ipn.Prefs](body)
 }
 
+// GetEffectivePolicy returns the effective policy for the specified scope.
+func (lc *LocalClient) GetEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
+	scopeID, err := scope.MarshalText()
+	if err != nil {
+		return nil, err
+	}
+	body, err := lc.get200(ctx, "/localapi/v0/policy/"+string(scopeID))
+	if err != nil {
+		return nil, err
+	}
+	return decodeJSON[*setting.Snapshot](body)
+}
+
+// ReloadEffectivePolicy reloads the effective policy for the specified scope
+// by reading and merging policy settings from all applicable policy sources.
+func (lc *LocalClient) ReloadEffectivePolicy(ctx context.Context, scope setting.PolicyScope) (*setting.Snapshot, error) {
+	scopeID, err := scope.MarshalText()
+	if err != nil {
+		return nil, err
+	}
+	body, err := lc.send(ctx, "POST", "/localapi/v0/policy/"+string(scopeID), 200, http.NoBody)
+	if err != nil {
+		return nil, err
+	}
+	return decodeJSON[*setting.Snapshot](body)
+}
+
 // GetDNSOSConfig returns the system DNS configuration for the current device.
 // That is, it returns the DNS configuration that the system would use if Tailscale weren't being used.
 func (lc *LocalClient) GetDNSOSConfig(ctx context.Context) (*apitype.DNSOSConfig, error) {

+ 1 - 1
cmd/k8s-operator/depaware.txt

@@ -814,7 +814,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
         tailscale.com/util/syspolicy/internal                        from tailscale.com/util/syspolicy/setting+
         tailscale.com/util/syspolicy/internal/loggerx                from tailscale.com/util/syspolicy/internal/metrics+
         tailscale.com/util/syspolicy/internal/metrics                from tailscale.com/util/syspolicy/source
-        tailscale.com/util/syspolicy/rsop                            from tailscale.com/util/syspolicy
+        tailscale.com/util/syspolicy/rsop                            from tailscale.com/util/syspolicy+
         tailscale.com/util/syspolicy/setting                         from tailscale.com/util/syspolicy+
         tailscale.com/util/syspolicy/source                          from tailscale.com/util/syspolicy+
         tailscale.com/util/sysresources                              from tailscale.com/wgengine/magicsock

+ 1 - 0
cmd/tailscale/cli/cli.go

@@ -185,6 +185,7 @@ change in the future.
 			logoutCmd,
 			switchCmd,
 			configureCmd,
+			syspolicyCmd,
 			netcheckCmd,
 			ipCmd,
 			dnsCmd,

+ 110 - 0
cmd/tailscale/cli/syspolicy.go

@@ -0,0 +1,110 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package cli
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"os"
+	"slices"
+	"text/tabwriter"
+
+	"github.com/peterbourgon/ff/v3/ffcli"
+	"tailscale.com/util/syspolicy/setting"
+)
+
+var syspolicyArgs struct {
+	json bool // JSON output mode
+}
+
+var syspolicyCmd = &ffcli.Command{
+	Name:       "syspolicy",
+	ShortHelp:  "Diagnose the MDM and system policy configuration",
+	LongHelp:   "The 'tailscale syspolicy' command provides tools for diagnosing the MDM and system policy configuration.",
+	ShortUsage: "tailscale syspolicy <subcommand>",
+	UsageFunc:  usageFuncNoDefaultValues,
+	Subcommands: []*ffcli.Command{
+		{
+			Name:       "list",
+			ShortUsage: "tailscale syspolicy list",
+			Exec:       runSysPolicyList,
+			ShortHelp:  "Prints effective policy settings",
+			LongHelp:   "The 'tailscale syspolicy list' subcommand displays the effective policy settings and their sources (e.g., MDM or environment variables).",
+			FlagSet: (func() *flag.FlagSet {
+				fs := newFlagSet("syspolicy list")
+				fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format")
+				return fs
+			})(),
+		},
+		{
+			Name:       "reload",
+			ShortUsage: "tailscale syspolicy reload",
+			Exec:       runSysPolicyReload,
+			ShortHelp:  "Forces a reload of policy settings, even if no changes are detected, and prints the result",
+			LongHelp:   "The 'tailscale syspolicy reload' subcommand forces a reload of policy settings, even if no changes are detected, and prints the result.",
+			FlagSet: (func() *flag.FlagSet {
+				fs := newFlagSet("syspolicy reload")
+				fs.BoolVar(&syspolicyArgs.json, "json", false, "output in JSON format")
+				return fs
+			})(),
+		},
+	},
+}
+
+func runSysPolicyList(ctx context.Context, args []string) error {
+	policy, err := localClient.GetEffectivePolicy(ctx, setting.DefaultScope())
+	if err != nil {
+		return err
+	}
+	printPolicySettings(policy)
+	return nil
+
+}
+
+func runSysPolicyReload(ctx context.Context, args []string) error {
+	policy, err := localClient.ReloadEffectivePolicy(ctx, setting.DefaultScope())
+	if err != nil {
+		return err
+	}
+	printPolicySettings(policy)
+	return nil
+}
+
+func printPolicySettings(policy *setting.Snapshot) {
+	if syspolicyArgs.json {
+		json, err := json.MarshalIndent(policy, "", "\t")
+		if err != nil {
+			errf("syspolicy marshalling error: %v", err)
+		} else {
+			outln(string(json))
+		}
+		return
+	}
+	if policy.Len() == 0 {
+		outln("No policy settings")
+		return
+	}
+
+	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+	fmt.Fprintln(w, "Name\tOrigin\tValue\tError")
+	fmt.Fprintln(w, "----\t------\t-----\t-----")
+	for _, k := range slices.Sorted(policy.Keys()) {
+		setting, _ := policy.GetSetting(k)
+		var origin string
+		if o := setting.Origin(); o != nil {
+			origin = o.String()
+		}
+		if err := setting.Error(); err != nil {
+			fmt.Fprintf(w, "%s\t%s\t\t{%s}\n", k, origin, err)
+		} else {
+			fmt.Fprintf(w, "%s\t%s\t%s\t\n", k, origin, setting.Value())
+		}
+	}
+	w.Flush()
+
+	fmt.Println()
+	return
+}

+ 1 - 1
cmd/tailscaled/depaware.txt

@@ -403,7 +403,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/util/syspolicy/internal                        from tailscale.com/util/syspolicy/setting+
         tailscale.com/util/syspolicy/internal/loggerx                from tailscale.com/util/syspolicy/internal/metrics+
         tailscale.com/util/syspolicy/internal/metrics                from tailscale.com/util/syspolicy/source
-        tailscale.com/util/syspolicy/rsop                            from tailscale.com/util/syspolicy
+        tailscale.com/util/syspolicy/rsop                            from tailscale.com/util/syspolicy+
         tailscale.com/util/syspolicy/setting                         from tailscale.com/util/syspolicy+
         tailscale.com/util/syspolicy/source                          from tailscale.com/util/syspolicy+
         tailscale.com/util/sysresources                              from tailscale.com/wgengine/magicsock

+ 50 - 0
ipn/localapi/localapi.go

@@ -62,6 +62,8 @@ import (
 	"tailscale.com/util/osdiag"
 	"tailscale.com/util/progresstracking"
 	"tailscale.com/util/rands"
+	"tailscale.com/util/syspolicy/rsop"
+	"tailscale.com/util/syspolicy/setting"
 	"tailscale.com/version"
 	"tailscale.com/wgengine/magicsock"
 )
@@ -76,6 +78,7 @@ var handler = map[string]localAPIHandler{
 	"cert/":     (*Handler).serveCert,
 	"file-put/": (*Handler).serveFilePut,
 	"files/":    (*Handler).serveFiles,
+	"policy/":   (*Handler).servePolicy,
 	"profiles/": (*Handler).serveProfiles,
 
 	// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
@@ -1332,6 +1335,53 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) {
 	e.Encode(prefs)
 }
 
+func (h *Handler) servePolicy(w http.ResponseWriter, r *http.Request) {
+	if !h.PermitRead {
+		http.Error(w, "policy access denied", http.StatusForbidden)
+		return
+	}
+
+	suffix, ok := strings.CutPrefix(r.URL.EscapedPath(), "/localapi/v0/policy/")
+	if !ok {
+		http.Error(w, "misconfigured", http.StatusInternalServerError)
+		return
+	}
+
+	var scope setting.PolicyScope
+	if suffix == "" {
+		scope = setting.DefaultScope()
+	} else if err := scope.UnmarshalText([]byte(suffix)); err != nil {
+		http.Error(w, fmt.Sprintf("%q is not a valid scope", suffix), http.StatusBadRequest)
+		return
+	}
+
+	policy, err := rsop.PolicyFor(scope)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	var effectivePolicy *setting.Snapshot
+	switch r.Method {
+	case "GET":
+		effectivePolicy = policy.Get()
+	case "POST":
+		effectivePolicy, err = policy.Reload()
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	default:
+		http.Error(w, "unsupported method", http.StatusMethodNotAllowed)
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	e := json.NewEncoder(w)
+	e.SetIndent("", "\t")
+	e.Encode(effectivePolicy)
+}
+
 type resJSON struct {
 	Error string `json:",omitempty"`
 }