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"`
}