diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 9cbd0e14e..b2c3900ee 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -1643,6 +1643,43 @@ func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyStat res.Body.Close() } +func (c *Auto) SetDeviceAttrs(ctx context.Context, attrs map[string]any) error { + return c.direct.SetDeviceAttrs(ctx, attrs) +} + +func (c *Direct) SetDeviceAttrs(ctx context.Context, attrs map[string]any) error { + np, err := c.getNoiseClient() + if err != nil { + return err + } + nodeKey, ok := c.GetPersist().PublicNodeKeyOK() + if !ok { + return errors.New("no node key") + } + if c.panicOnUse { + panic("tainted client") + } + // TODO(angott): at some point, update `Subsys` in the request to be `Warnable` + req := &tailcfg.SetDeviceAttributesRequest{ + NodeKey: nodeKey, + Version: tailcfg.CurrentCapabilityVersion, + Update: attrs, + } + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + res, err := np.doWithBody(ctx, "PATCH", "/machine/set-device-attr", nodeKey, req) + if err != nil { + return err + } + defer res.Body.Close() + all, _ := io.ReadAll(res.Body) + if res.StatusCode != 200 { + return fmt.Errorf("HTTP error from control plane: %v: %s", res.Status, all) + } + return nil +} + func addLBHeader(req *http.Request, nodeKey key.NodePublic) { if !nodeKey.IsZero() { req.Header.Add(tailcfg.LBHeader, nodeKey.String()) diff --git a/control/controlclient/noise.go b/control/controlclient/noise.go index 2e7c70fd1..4ac154402 100644 --- a/control/controlclient/noise.go +++ b/control/controlclient/noise.go @@ -9,6 +9,7 @@ "context" "encoding/json" "errors" + "log" "math" "net/http" "net/url" @@ -380,17 +381,21 @@ func (nc *NoiseClient) dial(ctx context.Context) (*noiseconn.Conn, error) { // post does a POST to the control server at the given path, JSON-encoding body. // The provided nodeKey is an optional load balancing hint. func (nc *NoiseClient) post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) { + return nc.doWithBody(ctx, "POST", path, nodeKey, body) +} + +func (nc *NoiseClient) doWithBody(ctx context.Context, method, path string, nodeKey key.NodePublic, body any) (*http.Response, error) { jbody, err := json.Marshal(body) if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, "POST", "https://"+nc.host+path, bytes.NewReader(jbody)) + req, err := http.NewRequestWithContext(ctx, method, "https://"+nc.host+path, bytes.NewReader(jbody)) if err != nil { return nil, err } addLBHeader(req, nodeKey) req.Header.Set("Content-Type", "application/json") - + log.Printf("XXXX doing %+v", req) conn, err := nc.getConn(ctx) if err != nil { return nil, err diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cbbea32aa..194c660b6 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -6325,6 +6325,16 @@ func (b *LocalBackend) SetExpirySooner(ctx context.Context, expiry time.Time) er return cc.SetExpirySooner(ctx, expiry) } +func (b *LocalBackend) SetDeviceAttrs(ctx context.Context, attrs map[string]any) error { + b.mu.Lock() + cc := b.ccAuto + b.mu.Unlock() + if cc == nil { + return errors.New("not running") + } + return cc.SetDeviceAttrs(ctx, attrs) +} + // exitNodeCanProxyDNS reports the DoH base URL ("http://foo/dns-query") without query parameters // to exitNodeID's DoH service, if available. // diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index dc8c08975..3d30c33c8 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -14,6 +14,7 @@ "errors" "fmt" "io" + "log" "maps" "mime" "mime/multipart" @@ -149,6 +150,7 @@ "usermetrics": (*Handler).serveUserMetrics, "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, + "alpha-set-device-attrs": (*Handler).serveSetDeviceAttrs, } var ( @@ -226,6 +228,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if fn, ok := handlerForPath(r.URL.Path); ok { fn(h, w, r) } else { + log.Printf("XXX nothing found for %q", r.URL.Path) http.NotFound(w, r) } } @@ -446,6 +449,29 @@ func (h *Handler) serveWhoIs(w http.ResponseWriter, r *http.Request) { h.serveWhoIsWithBackend(w, r, h.b) } +func (h *Handler) serveSetDeviceAttrs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !h.PermitWrite { + http.Error(w, "set-device-attrs access denied", http.StatusForbidden) + return + } + if r.Method != "PATCH" { + http.Error(w, "only PATCH allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := h.b.SetDeviceAttrs(ctx, req); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, "{}\n") +} + // localBackendWhoIsMethods is the subset of ipn.LocalBackend as needed // by the localapi WhoIs method. type localBackendWhoIsMethods interface { diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 897e8d27f..05e2d6ece 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -2450,6 +2450,13 @@ type HealthChangeRequest struct { NodeKey key.NodePublic } +type SetDeviceAttributesRequest struct { + Version CapabilityVersion + NodeKey key.NodePublic + + Update map[string]any // attribute name => {string, float64, int64, bool, nil to delete} +} + // SSHPolicy is the policy for how to handle incoming SSH connections // over Tailscale. type SSHPolicy struct {