diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index 635bcfa3d..a8a248e1e 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -628,7 +628,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { return nil } printFunnelStatus(ctx) - if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) { + if isServeConfigEmpty(sc) { printf("No serve config\n") return nil } @@ -636,18 +636,8 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { if err != nil { return err } - if sc.IsTCPForwardingAny() { - if err := printTCPStatusTree(ctx, sc, st); err != nil { - return err - } - printf("\n") - } - for hp := range sc.Web { - err := e.printWebStatusTree(sc, hp) - if err != nil { - return err - } - printf("\n") + if err := printServeStatusTrees(sc, st); err != nil { + return err } printFunnelWarning(sc) return nil @@ -660,15 +650,15 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S continue } hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p)))) - tlsStatus := "TLS over TCP" - if h.TerminateTLS != "" { - tlsStatus = "TLS terminated" - } fStatus := "tailnet only" if sc.AllowFunnel[hp] { fStatus = "Funnel on" } - printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus) + if h.TerminateTLS != "" { + printf("|-- tcp://%s (TLS-terminated TCP, %s)\n", hp, fStatus) + } else { + printf("|-- tcp://%s (%s)\n", hp, fStatus) + } for _, a := range st.TailscaleIPs { ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p))) printf("|-- tcp://%s\n", ipp) @@ -678,64 +668,75 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S return nil } -func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) error { - // No-op if no serve config - if sc == nil { +// printWebStatusTree renders one Web entry (the URL line plus its handler +// tree) for either a node-level serve or a service-level serve. When +// svcName is the empty string, the entry is treated as node-level. Service +// entries include the service name in the URL annotation. +// +// funnel and https are computed by the caller from the parent ServeConfig +// so this function does not need a reference to it. +func printWebStatusTree(wsc *ipn.WebServerConfig, hp ipn.HostPort, funnel, https bool, svcName tailcfg.ServiceName) error { + if wsc == nil { return nil } - fStatus := "tailnet only" - if sc.AllowFunnel[hp] { - fStatus = "Funnel on" - } host, portStr, _ := net.SplitHostPort(string(hp)) - - port, err := parseServePort(portStr) - if err != nil { - return fmt.Errorf("invalid port %q: %w", portStr, err) - } - scheme := "https" - if sc.IsServingHTTP(port, noService) { + if !https { scheme = "http" } - portPart := ":" + portStr if scheme == "http" && portStr == "80" || scheme == "https" && portStr == "443" { portPart = "" } - if scheme == "http" { - hostname, _, _ := strings.Cut(host, ".") - printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus) + + fStatus := "tailnet only" + if funnel { + fStatus = "Funnel on" } - printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus) - srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) { - switch { - case h.Path != "": - return "path", h.Path - case h.Proxy != "": - return "proxy", h.Proxy - case h.Text != "": - return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\"" + if svcName != "" { + printf("%s://%s%s (%s) (%s)\n", scheme, host, portPart, fStatus, svcName) + } else { + if scheme == "http" { + hostname, _, _ := strings.Cut(host, ".") + printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus) } - return "", "" + printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus) } - mounts := slicesx.MapKeys(sc.Web[hp].Handlers) + mounts := slicesx.MapKeys(wsc.Handlers) + if len(mounts) == 0 { + return nil + } sort.Slice(mounts, func(i, j int) bool { return len(mounts[i]) < len(mounts[j]) }) maxLen := len(mounts[len(mounts)-1]) for _, m := range mounts { - h := sc.Web[hp].Handlers[m] - t, d := srvTypeAndDesc(h) + h := wsc.Handlers[m] + t, d := serveHandlerDesc(h) printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d) } return nil } +// serveHandlerDesc returns the type label and description for an +// HTTPHandler, matching the format used by the Web tree printer for +// node-level and service-level serves. +func serveHandlerDesc(h *ipn.HTTPHandler) (string, string) { + switch { + case h.Path != "": + return "path", h.Path + case h.Proxy != "": + return "proxy", h.Proxy + case h.Text != "": + return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\"" + } + return "", "" +} + func elipticallyTruncate(s string, max int) string { if len(s) <= max { return s diff --git a/cmd/tailscale/cli/serve_status.go b/cmd/tailscale/cli/serve_status.go new file mode 100644 index 000000000..6ba85ca59 --- /dev/null +++ b/cmd/tailscale/cli/serve_status.go @@ -0,0 +1,120 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_serve + +package cli + +import ( + "context" + "maps" + "net" + "slices" + "strconv" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" +) + +// isServeConfigEmpty reports whether sc has no user-visible configuration +// to render in the non-JSON status output. +func isServeConfigEmpty(sc *ipn.ServeConfig) bool { + return sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.Services) == 0 && len(sc.AllowFunnel) == 0) +} + +// printServeStatusTrees prints the tree-style human-readable status of sc, +// including any node-level TCP and Web serve entries and any configured +// services, to [Stdout]. It does not print the funnel-status header, the +// no-config message, or the trailing funnel warning — callers are expected +// to handle those. +// +// Ordering is deterministic: node TCP forwards (existing behavior), then +// node Web entries by HostPort, then services by name. +func printServeStatusTrees(sc *ipn.ServeConfig, st *ipnstate.Status) error { + if sc == nil { + return nil + } + if sc.IsTCPForwardingAny() { + if err := printTCPStatusTree(context.Background(), sc, st); err != nil { + return err + } + printf("\n") + } + for _, hp := range slices.Sorted(maps.Keys(sc.Web)) { + _, portStr, _ := net.SplitHostPort(string(hp)) + port, err := parseServePort(portStr) + if err != nil { + return err + } + funnel := sc.AllowFunnel[hp] + https := !sc.IsServingHTTP(port, noService) + if err := printWebStatusTree(sc.Web[hp], hp, funnel, https, noService); err != nil { + return err + } + printf("\n") + } + for _, name := range slices.Sorted(maps.Keys(sc.Services)) { + if err := printServiceStatusTree(sc, st, name); err != nil { + return err + } + } + return nil +} + +// printServiceStatusTree prints the tree-style status for a single +// configured service. Each rendered URL/forward line is prefixed with the +// service name in the URL annotation (e.g. +// "https://db.example.ts.net (tailnet only) (svc:db)") so service entries +// are visually distinct from node-level serves. +func printServiceStatusTree(sc *ipn.ServeConfig, st *ipnstate.Status, name tailcfg.ServiceName) error { + svc, ok := sc.Services[name] + if !ok || svc == nil { + return nil + } + + if svc.Tun { + printf("tun (L3 forwarding) (%s)\n\n", name) + return nil + } + + suffix := "" + if st != nil && st.CurrentTailnet != nil { + suffix = st.CurrentTailnet.MagicDNSSuffix + } + host := name.WithoutPrefix() + if suffix != "" { + host = host + "." + suffix + } + + // TCP forwards configured directly on the service. + for _, p := range slices.Sorted(maps.Keys(svc.TCP)) { + h := svc.TCP[p] + if h == nil || h.TCPForward == "" { + continue + } + hp := ipn.HostPort(net.JoinHostPort(host, strconv.Itoa(int(p)))) + if h.TerminateTLS != "" { + printf("tcp://%s (TLS-terminated TCP, tailnet only) (%s)\n", hp, name) + } else { + printf("tcp://%s (tailnet only) (%s)\n", hp, name) + } + printf("|--> tcp://%s\n\n", h.TCPForward) + } + + // Web entries (HTTP/HTTPS). Services have no Funnel concept. + for _, hp := range slices.Sorted(maps.Keys(svc.Web)) { + _, portStr, _ := net.SplitHostPort(string(hp)) + port, err := parseServePort(portStr) + if err != nil { + return err + } + https := !sc.IsServingHTTP(port, name) + if err := printWebStatusTree(svc.Web[hp], hp, false, https, name); err != nil { + return err + } + printf("\n") + } + + return nil +} diff --git a/cmd/tailscale/cli/serve_status_test.go b/cmd/tailscale/cli/serve_status_test.go new file mode 100644 index 000000000..821a1d667 --- /dev/null +++ b/cmd/tailscale/cli/serve_status_test.go @@ -0,0 +1,308 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_serve + +package cli + +import ( + "bytes" + "encoding/json" + "io" + "net" + "strings" + "testing" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" + "tailscale.com/tstest" +) + +// statusTestStatus is a minimal ipnstate.Status used by serve-status tests. +var statusTestStatus = &ipnstate.Status{ + BackendState: ipn.Running.String(), + Self: &ipnstate.PeerStatus{ + DNSName: "foo.test.ts.net.", + }, + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, +} + +func TestPrintServeStatusTrees(t *testing.T) { + tests := []struct { + name string + sc *ipn.ServeConfig + want string + }{ + { + name: "node_web_tailnet_only", + sc: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + want: strings.Join([]string{ + "https://foo.test.ts.net (tailnet only)", + "|-- / proxy http://127.0.0.1:3000", + "", + "", + }, "\n"), + }, + { + name: "node_tcp_funnel_on", + sc: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{2222: {TCPForward: "127.0.0.1:22"}}, + AllowFunnel: map[ipn.HostPort]bool{ + "foo.test.ts.net:2222": true, + }, + }, + want: strings.Join([]string{ + "|-- tcp://foo.test.ts.net:2222 (Funnel on)", + "|--> tcp://127.0.0.1:22", + "", + "", + }, "\n"), + }, + { + name: "node_tls_terminated_tcp_tailnet", + sc: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {TCPForward: "127.0.0.1:8080", TerminateTLS: "foo.test.ts.net"}, + }, + }, + want: strings.Join([]string{ + "|-- tcp://foo.test.ts.net:443 (TLS-terminated TCP, tailnet only)", + "|--> tcp://127.0.0.1:8080", + "", + "", + }, "\n"), + }, + { + name: "service_web_only", + sc: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:db": { + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "db.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:5432"}, + }}, + }, + }, + }, + }, + want: strings.Join([]string{ + "https://db.test.ts.net (tailnet only) (svc:db)", + "|-- / proxy http://127.0.0.1:5432", + "", + "", + }, "\n"), + }, + { + name: "service_tcp_forward", + sc: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:ssh": { + TCP: map[uint16]*ipn.TCPPortHandler{2222: {TCPForward: "127.0.0.1:22"}}, + }, + }, + }, + want: strings.Join([]string{ + "tcp://ssh.test.ts.net:2222 (tailnet only) (svc:ssh)", + "|--> tcp://127.0.0.1:22", + "", + "", + }, "\n"), + }, + { + name: "service_tls_terminated_tcp", + sc: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {TCPForward: "127.0.0.1:8080", TerminateTLS: "foo.test.ts.net"}, + }, + }, + }, + }, + want: strings.Join([]string{ + "tcp://foo.test.ts.net:443 (TLS-terminated TCP, tailnet only) (svc:foo)", + "|--> tcp://127.0.0.1:8080", + "", + "", + }, "\n"), + }, + { + name: "service_tun", + sc: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:vpn": {Tun: true}, + }, + }, + want: strings.Join([]string{ + "tun (L3 forwarding) (svc:vpn)", + "", + "", + }, "\n"), + }, + { + name: "node_and_services_mixed", + sc: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + AllowFunnel: map[ipn.HostPort]bool{ + "foo.test.ts.net:443": true, + }, + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:db": { + TCP: map[uint16]*ipn.TCPPortHandler{5432: {TCPForward: "127.0.0.1:5432"}}, + }, + }, + }, + want: strings.Join([]string{ + "https://foo.test.ts.net (Funnel on)", + "|-- / proxy http://127.0.0.1:3000", + "", + "tcp://db.test.ts.net:5432 (tailnet only) (svc:db)", + "|--> tcp://127.0.0.1:5432", + "", + "", + }, "\n"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + tstest.Replace(t, &Stdout, io.Writer(&stdout)) + tstest.Replace(t, &Stderr, io.Writer(&stderr)) + + if err := printServeStatusTrees(tt.sc, statusTestStatus); err != nil { + t.Fatalf("printServeStatusTrees: %v", err) + } + if got := stdout.String(); got != tt.want { + t.Errorf("\nGot:\n%q\nExpected:\n%q", got, tt.want) + } + if got := stderr.String(); got != "" { + t.Errorf("unexpected Stderr output: %q", got) + } + }) + } +} + +// TestPrintServeStatusTreesParity asserts that the host-identifying keys +// visible in the JSON serialization of a ServeConfig also appear in the +// human-readable output, so the two views stay in lockstep. This is the +// parity contract from issue #34163. +// +// It checks: +// - every Services key (service name) +// - every node-level Web HostPort host +// - every service-level Web HostPort host +// - every node-level TCP forward as a host:port string +// - every tun-mode service rendering the "tun" marker after its name +func TestPrintServeStatusTreesParity(t *testing.T) { + sc := &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {HTTPS: true}, + 2222: {TCPForward: "127.0.0.1:22"}, + }, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + AllowFunnel: map[ipn.HostPort]bool{ + "foo.test.ts.net:2222": true, + }, + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:db": { + TCP: map[uint16]*ipn.TCPPortHandler{5432: {TCPForward: "127.0.0.1:5432"}}, + }, + "svc:web": { + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "web.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/api": {Proxy: "http://127.0.0.1:9000"}, + }}, + }, + }, + "svc:vpn": {Tun: true}, + }, + } + + // Marshal to JSON and reparse as a generic map so the parity check walks + // the same wire shape clients see, not the typed Go values. + jsonBytes, err := json.Marshal(sc) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(jsonBytes, &raw); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + + var stdout, stderr bytes.Buffer + tstest.Replace(t, &Stdout, io.Writer(&stdout)) + tstest.Replace(t, &Stderr, io.Writer(&stderr)) + + if err := printServeStatusTrees(sc, statusTestStatus); err != nil { + t.Fatalf("printServeStatusTrees: %v", err) + } + if got := stderr.String(); got != "" { + t.Errorf("unexpected Stderr output: %q", got) + } + human := stdout.String() + + services, _ := raw["Services"].(map[string]any) + for name, sval := range services { + if !strings.Contains(human, name) { + t.Errorf("human output missing service name %q\n--- human ---\n%s", name, human) + } + svc, _ := sval.(map[string]any) + if tun, _ := svc["Tun"].(bool); tun { + tunLine := "tun (L3 forwarding) (" + name + ")" + if !strings.Contains(human, tunLine) { + t.Errorf("human output missing tun marker for %q\n--- human ---\n%s", tunLine, human) + } + } + web, _ := svc["Web"].(map[string]any) + for hp := range web { + host := strings.SplitN(hp, ":", 2)[0] + if !strings.Contains(human, host) { + t.Errorf("human output missing service %s Web host %q\n--- human ---\n%s", name, host, human) + } + } + } + + if web, ok := raw["Web"].(map[string]any); ok { + for hp := range web { + host := strings.SplitN(hp, ":", 2)[0] + if !strings.Contains(human, host) { + t.Errorf("human output missing node Web host %q\n--- human ---\n%s", host, human) + } + } + } + + nodeHost := strings.TrimSuffix(statusTestStatus.Self.DNSName, ".") + if tcp, ok := raw["TCP"].(map[string]any); ok { + for portStr, hVal := range tcp { + h, _ := hVal.(map[string]any) + fwd, _ := h["TCPForward"].(string) + if fwd == "" { + continue + } + hostport := net.JoinHostPort(nodeHost, portStr) + if !strings.Contains(human, hostport) { + t.Errorf("human output missing node TCP forward %q\n--- human ---\n%s", hostport, human) + } + } + } +}