Created
April 8, 2026 15:17
-
-
Save bouk/db8a53450ca169eb6688cc2a30d1b08f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // tailscaleservesetconfig applies a tailscale services config file via the | |
| // local API, similar to `tailscale serve set-config --all`, but preserves the | |
| // current advertised state for services where the "advertised" field is not | |
| // explicitly set in the config file. | |
| // | |
| // With `tailscale serve set-config --all`, an unset "advertised" field defaults | |
| // to true. This tool instead keeps the existing value from prefs, so deploying | |
| // a config file that omits "advertised" won't accidentally start or stop | |
| // advertising a service. | |
| // | |
| // The implementation mirrors runServeSetConfig from the tailscale CLI: | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L803-L911 | |
| // | |
| // The only behavioral difference is in how the advertised services list is | |
| // built: instead of defaulting unset Advertised to true, we look up the | |
| // service's current state in prefs and preserve it. | |
| package main | |
| import ( | |
| "context" | |
| "errors" | |
| "fmt" | |
| "log" | |
| "net" | |
| "net/url" | |
| "os" | |
| "path/filepath" | |
| "slices" | |
| "strings" | |
| "tailscale.com/client/local" | |
| "tailscale.com/ipn" | |
| "tailscale.com/ipn/conffile" | |
| "tailscale.com/tailcfg" | |
| "tailscale.com/types/ipproto" | |
| "tailscale.com/util/mak" | |
| ) | |
| // serveType, serveTypeFromConfString: copied from | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L166-L190 | |
| type serveType int | |
| const ( | |
| serveTypeHTTPS serveType = iota | |
| serveTypeHTTP | |
| serveTypeTCP | |
| serveTypeTLSTerminatedTCP | |
| serveTypeTUN | |
| ) | |
| func serveTypeFromConfString(sp conffile.ServiceProtocol) (st serveType, ok bool) { | |
| switch sp { | |
| case conffile.ProtoHTTP: | |
| return serveTypeHTTP, true | |
| case conffile.ProtoHTTPS, conffile.ProtoHTTPSInsecure, conffile.ProtoFile: | |
| return serveTypeHTTPS, true | |
| case conffile.ProtoTCP: | |
| return serveTypeTCP, true | |
| case conffile.ProtoTLSTerminatedTCP: | |
| return serveTypeTLSTerminatedTCP, true | |
| case conffile.ProtoTUN: | |
| return serveTypeTUN, true | |
| } | |
| return -1, false | |
| } | |
| // run mirrors runServeSetConfig (--all mode) from the tailscale CLI: | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L803-L911 | |
| // | |
| // Differences from the original: | |
| // - Always operates in --all mode (no --service flag). | |
| // - Dropped allowFunnel / caps / proxyProtocol args (unused for services). | |
| // - When Advertised is unset, preserves the current value from prefs instead | |
| // of defaulting to true (lines 876-878 in the original). | |
| func run() error { | |
| if len(os.Args) != 2 { | |
| return fmt.Errorf("usage: tailscaleservesetconfig <config-file>") | |
| } | |
| scf, err := conffile.LoadServicesConfig(os.Args[1], "") | |
| if err != nil { | |
| return fmt.Errorf("could not read config from file %q: %w", os.Args[1], err) | |
| } | |
| ctx := context.Background() | |
| lc := local.Client{} | |
| st, err := lc.StatusWithoutPeers(ctx) | |
| if err != nil { | |
| return fmt.Errorf("getting client status: %w", err) | |
| } | |
| magicDNSSuffix := st.CurrentTailnet.MagicDNSSuffix | |
| sc, err := lc.GetServeConfig(ctx) | |
| if err != nil { | |
| return fmt.Errorf("getting current serve config: %w", err) | |
| } | |
| prefs, err := lc.GetPrefs(ctx) | |
| if err != nil { | |
| return fmt.Errorf("getting current prefs: %w", err) | |
| } | |
| // Clear all existing service config. | |
| sc.Services = map[tailcfg.ServiceName]*ipn.ServiceConfig{} | |
| var advertisedServices []string | |
| for name, details := range scf.Services { | |
| for ppr, ep := range details.Endpoints { | |
| if ep.Protocol == conffile.ProtoTUN { | |
| err := setServe(sc, name.String(), serveTypeTUN, 0, "", "", magicDNSSuffix) | |
| if err != nil { | |
| return err | |
| } | |
| // TUN mode is exclusive. | |
| break | |
| } | |
| if ppr.Proto != int(ipproto.TCP) { | |
| return fmt.Errorf("service %q: source ports must be TCP", name) | |
| } | |
| srvType, _ := serveTypeFromConfString(ep.Protocol) | |
| for port := ppr.Ports.First; port <= ppr.Ports.Last; port++ { | |
| var target string | |
| if ep.Protocol == conffile.ProtoFile { | |
| target = ep.Destination | |
| } else { | |
| // map source port range 1-1 to destination port range | |
| destPort := ep.DestinationPorts.First + (port - ppr.Ports.First) | |
| portStr := fmt.Sprint(destPort) | |
| target = fmt.Sprintf("%s://%s", ep.Protocol, net.JoinHostPort(ep.Destination, portStr)) | |
| } | |
| err := setServe(sc, name.String(), srvType, port, "/", target, magicDNSSuffix) | |
| if err != nil { | |
| return fmt.Errorf("service %q: %w", name, err) | |
| } | |
| } | |
| } | |
| // This is the key difference from the original: when Advertised is unset, | |
| // preserve the current state from prefs instead of defaulting to true. | |
| if v, isSet := details.Advertised.Get(); isSet { | |
| if v { | |
| advertisedServices = append(advertisedServices, name.String()) | |
| } | |
| } else { | |
| if slices.Contains(prefs.AdvertiseServices, name.String()) { | |
| advertisedServices = append(advertisedServices, name.String()) | |
| } | |
| } | |
| } | |
| _, err = lc.EditPrefs(ctx, &ipn.MaskedPrefs{ | |
| AdvertiseServicesSet: true, | |
| Prefs: ipn.Prefs{ | |
| AdvertiseServices: advertisedServices, | |
| }, | |
| }) | |
| if err != nil { | |
| return err | |
| } | |
| return lc.SetServeConfig(ctx, sc) | |
| } | |
| // setServe mirrors serveEnv.setServe from the tailscale CLI: | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L913-L947 | |
| // | |
| // Dropped: allowFunnel (not supported for services), caps, proxyProtocol. | |
| // Dropped: applyFunnel call (services-only tool, funnel is node-level). | |
| func setServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string, target string, mds string) error { | |
| switch srvType { | |
| case serveTypeHTTPS, serveTypeHTTP: | |
| useTLS := srvType == serveTypeHTTPS | |
| err := applyWebServe(sc, dnsName, srvPort, useTLS, mount, target, mds) | |
| if err != nil { | |
| return fmt.Errorf("failed apply web serve: %w", err) | |
| } | |
| case serveTypeTCP, serveTypeTLSTerminatedTCP: | |
| err := applyTCPServe(sc, dnsName, srvType, srvPort, target, mds) | |
| if err != nil { | |
| return fmt.Errorf("failed to apply TCP serve: %w", err) | |
| } | |
| case serveTypeTUN: | |
| svcName := tailcfg.ServiceName(dnsName) | |
| if _, ok := sc.Services[svcName]; !ok { | |
| mak.Set(&sc.Services, svcName, new(ipn.ServiceConfig)) | |
| } | |
| sc.Services[svcName].Tun = true | |
| default: | |
| return fmt.Errorf("invalid type %q", srvType) | |
| } | |
| return nil | |
| } | |
| // applyWebServe mirrors serveEnv.applyWebServe from the tailscale CLI: | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L1157-L1204 | |
| // | |
| // Dropped: caps (AcceptAppCaps), macOS sandbox check (version.IsMacAppStore). | |
| func applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target, mds string) error { | |
| h := new(ipn.HTTPHandler) | |
| switch { | |
| case strings.HasPrefix(target, "text:"): | |
| text := strings.TrimPrefix(target, "text:") | |
| if text == "" { | |
| return errors.New("unable to serve; text cannot be an empty string") | |
| } | |
| h.Text = text | |
| case filepath.IsAbs(target): | |
| target = filepath.Clean(target) | |
| fi, err := os.Stat(target) | |
| if err != nil { | |
| return errors.New("invalid path") | |
| } | |
| if fi.IsDir() && !strings.HasSuffix(mount, "/") { | |
| mount += "/" | |
| } | |
| h.Path = target | |
| default: | |
| t, err := ipn.ExpandProxyTargetValue(target, []string{"http", "https", "https+insecure", "unix"}, "http") | |
| if err != nil { | |
| return err | |
| } | |
| h.Proxy = t | |
| } | |
| svcName := tailcfg.AsServiceName(dnsName) | |
| if sc.IsTCPForwardingOnPort(srvPort, svcName) { | |
| return errors.New("cannot serve web; already serving TCP") | |
| } | |
| sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS, mds) | |
| return nil | |
| } | |
| // applyTCPServe mirrors serveEnv.applyTCPServe from the tailscale CLI: | |
| // https://github.com/tailscale/tailscale/blob/v1.94.2/cmd/tailscale/cli/serve_v2.go#L1206-L1247 | |
| // | |
| // Dropped: proxyProtocol (always 0 for set-config). | |
| func applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string, mds string) error { | |
| var terminateTLS bool | |
| switch srcType { | |
| case serveTypeTCP: | |
| terminateTLS = false | |
| case serveTypeTLSTerminatedTCP: | |
| terminateTLS = true | |
| default: | |
| return fmt.Errorf("invalid TCP target %q", target) | |
| } | |
| svcName := tailcfg.AsServiceName(dnsName) | |
| targetURL, err := ipn.ExpandProxyTargetValue(target, []string{"tcp"}, "tcp") | |
| if err != nil { | |
| return fmt.Errorf("unable to expand target: %v", err) | |
| } | |
| dstURL, err := url.Parse(targetURL) | |
| if err != nil { | |
| return fmt.Errorf("invalid TCP target %q: %v", target, err) | |
| } | |
| if sc.IsServingWeb(srcPort, svcName) { | |
| return fmt.Errorf("cannot serve TCP; already serving web on %d for %s", srcPort, dnsName) | |
| } | |
| if svcName != "" { | |
| sc.SetTCPForwardingForService(srcPort, dstURL.Host, terminateTLS, svcName, 0, mds) | |
| return nil | |
| } | |
| sc.SetTCPForwarding(srcPort, dstURL.Host, terminateTLS, 0, dnsName) | |
| return nil | |
| } | |
| func main() { | |
| log.SetFlags(0) | |
| if err := run(); err != nil { | |
| log.Fatal(err) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment