Skip to content

Instantly share code, notes, and snippets.

@bouk
Created April 8, 2026 15:17
Show Gist options
  • Select an option

  • Save bouk/db8a53450ca169eb6688cc2a30d1b08f to your computer and use it in GitHub Desktop.

Select an option

Save bouk/db8a53450ca169eb6688cc2a30d1b08f to your computer and use it in GitHub Desktop.
// 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