~stepbrobd/tailscale

3f626c0d774bc1b8a93be26a4aa8f2dadeb27ece — Nick Khyl a month ago 45354da
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 <nickk@tailscale.com>
M client/tailscale/localclient.go => client/tailscale/localclient.go +28 -0
@@ 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) {

M cmd/k8s-operator/depaware.txt => cmd/k8s-operator/depaware.txt +1 -1
@@ 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

M cmd/tailscale/cli/cli.go => cmd/tailscale/cli/cli.go +1 -0
@@ 185,6 185,7 @@ change in the future.
			logoutCmd,
			switchCmd,
			configureCmd,
			syspolicyCmd,
			netcheckCmd,
			ipCmd,
			dnsCmd,

A cmd/tailscale/cli/syspolicy.go => cmd/tailscale/cli/syspolicy.go +110 -0
@@ 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
}

M cmd/tailscaled/depaware.txt => cmd/tailscaled/depaware.txt +1 -1
@@ 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

M ipn/localapi/localapi.go => ipn/localapi/localapi.go +50 -0
@@ 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"`
}