diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index a3a4bc16c..93a9d1641 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -859,6 +859,33 @@ type signRequest struct { return nil } +// SetServeConfig sets or replaces the serving settings. +// If config is nil, settings are cleared and serving is disabled. +func (lc *LocalClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { + b, err := json.Marshal(&config) + if err != nil { + return fmt.Errorf("encoding config: %w", err) + } + _, err = lc.send(ctx, "POST", "/localapi/v0/serve-config", 200, bytes.NewReader(b)) + if err != nil { + return fmt.Errorf("sending serve config: %w", err) + } + return nil +} + +// GetServeConfig return the current serve config. +func (lc *LocalClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) { + body, err := lc.send(ctx, "GET", "/localapi/v0/serve-config", 200, nil) + if err != nil { + return nil, fmt.Errorf("getting serve config: %w", err) + } + sc := new(ipn.ServeConfig) + if err := json.Unmarshal(body, sc); err != nil { + return nil, err + } + return sc, nil +} + // tailscaledConnectHint gives a little thing about why tailscaled (or // platform equivalent) is not answering localapi connections. // diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index eb0e41748..4859e3ffc 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -65,7 +65,7 @@ func (src *ServeConfig) Clone() *ServeConfig { dst := new(ServeConfig) *dst = *src if dst.TCP != nil { - dst.TCP = map[int]*TCPPortHandler{} + dst.TCP = map[uint16]*TCPPortHandler{} for k, v := range src.TCP { dst.TCP[k] = v.Clone() } @@ -87,7 +87,7 @@ func (src *ServeConfig) Clone() *ServeConfig { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct { - TCP map[int]*TCPPortHandler + TCP map[uint16]*TCPPortHandler Web map[HostPort]*WebServerConfig AllowIngress map[HostPort]bool }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index e3a65e187..a85da3e3f 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -164,7 +164,7 @@ func (v *ServeConfigView) UnmarshalJSON(b []byte) error { return nil } -func (v ServeConfigView) TCP() views.MapFn[int, *TCPPortHandler, TCPPortHandlerView] { +func (v ServeConfigView) TCP() views.MapFn[uint16, *TCPPortHandler, TCPPortHandlerView] { return views.MapFnOf(v.ж.TCP, func(t *TCPPortHandler) TCPPortHandlerView { return t.View() }) @@ -182,7 +182,7 @@ func (v ServeConfigView) AllowIngress() views.Map[HostPort, bool] { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _ServeConfigViewNeedsRegeneration = ServeConfig(struct { - TCP map[int]*TCPPortHandler + TCP map[uint16]*TCPPortHandler Web map[HostPort]*WebServerConfig AllowIngress map[HostPort]bool }{}) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c7ca64703..9aa72e5e9 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -11,7 +11,6 @@ "errors" "fmt" "io" - "math" "net" "net/http" "net/netip" @@ -3522,8 +3521,8 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked() { } } if b.serveConfig.Valid() { - b.serveConfig.TCP().Range(func(port int, _ ipn.TCPPortHandlerView) bool { - if port > 0 && port <= math.MaxUint16 { + b.serveConfig.TCP().Range(func(port uint16, _ ipn.TCPPortHandlerView) bool { + if port > 0 { handlePorts = append(handlePorts, uint16(port)) } return true @@ -3534,6 +3533,46 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked() { b.setTCPPortsIntercepted(handlePorts) } +// SetServeConfig establishes or replaces the current serve config. +func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + nm := b.netMap + if nm == nil { + return errors.New("netMap is nil") + } + if nm.SelfNode == nil { + return errors.New("netMap SelfNode is nil") + } + profileID := fmt.Sprintf("node-%s", nm.SelfNode.StableID) // TODO(maisem,bradfitz): something else? + confKey := ipn.ServeConfigKey(profileID) + + var bs []byte + if config != nil { + j, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("encoding serve config: %w", err) + } + bs = j + } + if err := b.store.WriteState(confKey, bs); err != nil { + return fmt.Errorf("writing ServeConfig to StateStore: %w", err) + } + + b.setTCPPortsInterceptedFromNetmapAndPrefsLocked() + + return nil +} + +// ServeConfig provides a view of the current serve mappings. +// If serving is not configured, the returned view is not Valid. +func (b *LocalBackend) ServeConfig() ipn.ServeConfigView { + b.mu.Lock() + defer b.mu.Unlock() + return b.serveConfig +} + // operatorUserName returns the current pref's OperatorUser's name, or the // empty string if none. func (b *LocalBackend) operatorUserName() string { diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index b12100432..1137e687a 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -82,7 +82,7 @@ func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.Addr return } - tcph, ok := sc.TCP().GetOk(int(dport)) + tcph, ok := sc.TCP().GetOk(dport) if !ok { b.logf("[unexpected] localbackend: got TCP conn without TCP config for port %v; from %v", dport, srcAddr) sendRST() diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index b0a6aa919..28fef37da 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -72,6 +72,7 @@ "ping": (*Handler).servePing, "prefs": (*Handler).servePrefs, "pprof": (*Handler).servePprof, + "serve-config": (*Handler).serveServeConfig, "set-dns": (*Handler).serveSetDNS, "set-expiry-sooner": (*Handler).serveSetExpirySooner, "status": (*Handler).serveStatus, @@ -455,6 +456,41 @@ func (h *Handler) servePprof(w http.ResponseWriter, r *http.Request) { servePprofFunc(w, r) } +func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "serve config denied", http.StatusForbidden) + return + } + + w.Header().Set("Content-Type", "application/json") + + switch r.Method { + case "GET": + config := h.b.ServeConfig() + json.NewEncoder(w).Encode(config) + case "POST": + configIn := new(ipn.ServeConfig) + if err := json.NewDecoder(r.Body).Decode(configIn); err != nil { + json.NewEncoder(w).Encode(struct { + Error error + }{ + Error: fmt.Errorf("decoding config: %w", err), + }) + return + } + err := h.b.SetServeConfig(configIn) + if err != nil { + json.NewEncoder(w).Encode(struct { + Error error + }{ + Error: fmt.Errorf("updating config: %w", err), + }) + return + } + w.WriteHeader(http.StatusOK) + } +} + func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "IP forwarding check access denied", http.StatusForbidden) diff --git a/ipn/store.go b/ipn/store.go index 840978ce1..6579fa200 100644 --- a/ipn/store.go +++ b/ipn/store.go @@ -76,7 +76,7 @@ func ServeConfigKey(profileID string) StateKey { type ServeConfig struct { // TCP are the list of TCP port numbers that tailscaled should handle for // the Tailscale IP addresses. (not subnet routers, etc) - TCP map[int]*TCPPortHandler `json:",omitempty"` + TCP map[uint16]*TCPPortHandler `json:",omitempty"` // Web maps from "$SNI_NAME:$PORT" to a set of HTTP handlers // keyed by mount point ("/", "/foo", etc) @@ -84,7 +84,7 @@ type ServeConfig struct { // AllowIngress is the set of SNI:port values for which ingress // traffic is allowed, from trusted ingress peers. - AllowIngress map[HostPort]bool + AllowIngress map[HostPort]bool `json:",omitempty"` } // HostPort is an SNI name and port number, joined by a colon.