Compare commits
71 Commits
Xe/tsnet-f
...
release-br
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043a34500d | ||
|
|
214217dd10 | ||
|
|
00205f0ab6 | ||
|
|
61f36aa1cd | ||
|
|
296d6820b5 | ||
|
|
383b7c747a | ||
|
|
c3301abc5e | ||
|
|
49e305f862 | ||
|
|
71a5f2a989 | ||
|
|
1b1ac05d95 | ||
|
|
e6b81f983e | ||
|
|
8414c591e5 | ||
|
|
0651c1a069 | ||
|
|
2474bd2754 | ||
|
|
40091d0261 | ||
|
|
d216363bc5 | ||
|
|
dbbc465bfd | ||
|
|
598b24d85c | ||
|
|
17c6d5c7c5 | ||
|
|
47ebe6f956 | ||
|
|
c750186830 | ||
|
|
d7bbd4fe03 | ||
|
|
ac0c0b081d | ||
|
|
068ed7dbfa | ||
|
|
26bf7c4dbe | ||
|
|
d47b74e461 | ||
|
|
3db61d07ca | ||
|
|
817aa282c2 | ||
|
|
d00c046b72 | ||
|
|
aad01c81b1 | ||
|
|
fd558e2e68 | ||
|
|
3eeff9e7f7 | ||
|
|
6c0e6a5f4e | ||
|
|
10d462d321 | ||
|
|
51b0169b10 | ||
|
|
b4d3e2928b | ||
|
|
2b892ad6e7 | ||
|
|
6ef2105a8e | ||
|
|
8c4adde083 | ||
|
|
c87782ba9d | ||
|
|
09e0ccf4c2 | ||
|
|
a1d9f65354 | ||
|
|
5e8a80b845 | ||
|
|
558735bc63 | ||
|
|
489e27f085 | ||
|
|
56526ff57f | ||
|
|
09aed46d44 | ||
|
|
223713d4a1 | ||
|
|
83fa17d26c | ||
|
|
958c89470b | ||
|
|
e109cf9fdd | ||
|
|
3ff44b2307 | ||
|
|
ccdd534e81 | ||
|
|
047b324933 | ||
|
|
f0d6228c52 | ||
|
|
920de86cee | ||
|
|
b64d78d58f | ||
|
|
ea81bffdeb | ||
|
|
1e72de6b72 | ||
|
|
92fc243755 | ||
|
|
3471fbf8dc | ||
|
|
b797f773c7 | ||
|
|
dad78f31f3 | ||
|
|
be027a9899 | ||
|
|
87b4bbb94f | ||
|
|
4c2f67a1d0 | ||
|
|
e69682678f | ||
|
|
a2be1aabfa | ||
|
|
ce99474317 | ||
|
|
f4f8ed98d9 | ||
|
|
6eca47b16c |
@@ -1 +1 @@
|
||||
1.37.0
|
||||
1.38.4
|
||||
|
||||
@@ -103,7 +103,7 @@ func (c *Client) ACL(ctx context.Context) (acl *ACL, err error) {
|
||||
// it as a string.
|
||||
// HuJSON is JSON with a few modifications to make it more human-friendly. The primary
|
||||
// changes are allowing comments and trailing comments. See the following links for more info:
|
||||
// https://tailscale.com/kb/1018/acls?q=acl#tailscale-acl-policy-format
|
||||
// https://tailscale.com/s/acl-format
|
||||
// https://github.com/tailscale/hujson
|
||||
func (c *Client) ACLHuJSON(ctx context.Context) (acl *ACLHuJSON, err error) {
|
||||
// Format return errors to be descriptive.
|
||||
|
||||
@@ -850,6 +850,30 @@ func (lc *LocalClient) NetworkLockInit(ctx context.Context, keys []tka.Key, disa
|
||||
return decodeJSON[*ipnstate.NetworkLockStatus](body)
|
||||
}
|
||||
|
||||
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
|
||||
// enable unattended bringup in the locked tailnet.
|
||||
func (lc *LocalClient) NetworkLockWrapPreauthKey(ctx context.Context, preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
encodedPrivate, err := tkaKey.MarshalText()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
type wrapRequest struct {
|
||||
TSKey string
|
||||
TKAKey string // key.NLPrivate.MarshalText
|
||||
}
|
||||
if err := json.NewEncoder(&b).Encode(wrapRequest{TSKey: preauthKey, TKAKey: string(encodedPrivate)}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/tka/wrap-preauth-key", 200, &b)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error: %w", err)
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// NetworkLockModify adds and/or removes key(s) to the tailnet key authority.
|
||||
func (lc *LocalClient) NetworkLockModify(ctx context.Context, addKeys, removeKeys []tka.Key) error {
|
||||
var b bytes.Buffer
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// get-authkey allocates an authkey using an OAuth API client
|
||||
// https://tailscale.com/kb/1215/oauth-clients/ and prints it
|
||||
// https://tailscale.com/s/oauth-clients and prints it
|
||||
// to stdout for scripts to capture and use.
|
||||
package main
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ metadata:
|
||||
name: tailscale-auth-proxy
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["users"]
|
||||
resources: ["users", "groups"]
|
||||
verbs: ["impersonate"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
||||
@@ -235,15 +235,11 @@ waitOnline:
|
||||
|
||||
startlog.Infof("Startup complete, operator running")
|
||||
if shouldRunAuthProxy {
|
||||
rc, err := rest.TransportFor(restConfig)
|
||||
rt, err := rest.TransportFor(restConfig)
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not get rest transport: %v", err)
|
||||
}
|
||||
authProxyListener, err := s.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
startlog.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
go runAuthProxy(lc, authProxyListener, rc, zlog.Named("auth-proxy").Infof)
|
||||
go runAuthProxy(s, rt, zlog.Named("auth-proxy").Infof)
|
||||
}
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
startlog.Fatalf("could not start manager: %v", err)
|
||||
|
||||
@@ -5,10 +5,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -17,6 +15,7 @@ import (
|
||||
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
@@ -41,23 +40,42 @@ func (h *authProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripper, logf logger.Logf) {
|
||||
// runAuthProxy runs an HTTP server that authenticates requests using the
|
||||
// Tailscale LocalAPI and then proxies them to the Kubernetes API.
|
||||
// It listens on :443 and uses the Tailscale HTTPS certificate.
|
||||
// s will be started if it is not already running.
|
||||
// rt is used to proxy requests to the Kubernetes API.
|
||||
//
|
||||
// It never returns.
|
||||
func runAuthProxy(s *tsnet.Server, rt http.RoundTripper, logf logger.Logf) {
|
||||
ln, err := s.ListenTLS("tcp", ":443")
|
||||
if err != nil {
|
||||
log.Fatalf("could not listen on :443: %v", err)
|
||||
}
|
||||
u, err := url.Parse(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
|
||||
if err != nil {
|
||||
log.Fatalf("runAuthProxy: failed to parse URL %v", err)
|
||||
}
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatalf("could not get local client: %v", err)
|
||||
}
|
||||
ap := &authProxy{
|
||||
logf: logf,
|
||||
lc: lc,
|
||||
rp: &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
// Replace the request with the user's identity.
|
||||
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
|
||||
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
|
||||
// We want to proxy to the Kubernetes API, but we want to use
|
||||
// the caller's identity to do so. We do this by impersonating
|
||||
// the caller using the Kubernetes User Impersonation feature:
|
||||
// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
|
||||
|
||||
// Remove all authentication headers.
|
||||
// Out of paranoia, remove all authentication headers that might
|
||||
// have been set by the client.
|
||||
r.Header.Del("Authorization")
|
||||
r.Header.Del("Impersonate-Group")
|
||||
r.Header.Del("Impersonate-User")
|
||||
r.Header.Del("Impersonate-Uid")
|
||||
for k := range r.Header {
|
||||
if strings.HasPrefix(k, "Impersonate-Extra-") {
|
||||
@@ -65,6 +83,19 @@ func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripp
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the impersonation headers that we want.
|
||||
who := r.Context().Value(whoIsKey{}).(*apitype.WhoIsResponse)
|
||||
if who.Node.IsTagged() {
|
||||
// Use the nodes FQDN as the username, and the nodes tags as the groups.
|
||||
// "Impersonate-Group" requires "Impersonate-User" to be set.
|
||||
r.Header.Set("Impersonate-User", strings.TrimSuffix(who.Node.Name, "."))
|
||||
for _, tag := range who.Node.Tags {
|
||||
r.Header.Add("Impersonate-Group", tag)
|
||||
}
|
||||
} else {
|
||||
r.Header.Set("Impersonate-User", who.UserProfile.LoginName)
|
||||
}
|
||||
|
||||
// Replace the URL with the Kubernetes APIServer.
|
||||
r.URL.Scheme = u.Scheme
|
||||
r.URL.Host = u.Host
|
||||
@@ -72,9 +103,7 @@ func runAuthProxy(lc *tailscale.LocalClient, ls net.Listener, rt http.RoundTripp
|
||||
Transport: rt,
|
||||
},
|
||||
}
|
||||
if err := http.Serve(tls.NewListener(ls, &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
}), ap); err != nil {
|
||||
if err := http.Serve(ln, ap); err != nil {
|
||||
log.Fatalf("runAuthProxy: failed to serve %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if len(info.Node.Tags) != 0 {
|
||||
if info.Node.IsTagged() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
log.Printf("node %s is tagged", info.Node.Hostinfo.Hostname())
|
||||
return
|
||||
|
||||
@@ -147,7 +147,7 @@ func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, i
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to identify remote host: %w", err)
|
||||
}
|
||||
if len(whois.Node.Tags) != 0 {
|
||||
if whois.Node.IsTagged() {
|
||||
return nil, fmt.Errorf("tagged nodes are not users")
|
||||
}
|
||||
if whois.UserProfile == nil || whois.UserProfile.LoginName == "" {
|
||||
|
||||
@@ -113,12 +113,15 @@ change in the future.
|
||||
loginCmd,
|
||||
logoutCmd,
|
||||
switchCmd,
|
||||
configureCmd,
|
||||
netcheckCmd,
|
||||
ipCmd,
|
||||
statusCmd,
|
||||
pingCmd,
|
||||
ncCmd,
|
||||
sshCmd,
|
||||
funnelCmd,
|
||||
serveCmd,
|
||||
versionCmd,
|
||||
webCmd,
|
||||
fileCmd,
|
||||
@@ -146,12 +149,8 @@ change in the future.
|
||||
switch {
|
||||
case slices.Contains(args, "debug"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
|
||||
case slices.Contains(args, "serve"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd)
|
||||
case slices.Contains(args, "update"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, updateCmd)
|
||||
case slices.Contains(args, "configure"):
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureCmd)
|
||||
}
|
||||
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
|
||||
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
|
||||
|
||||
@@ -26,12 +26,14 @@ func init() {
|
||||
|
||||
var configureKubeconfigCmd = &ffcli.Command{
|
||||
Name: "kubeconfig",
|
||||
ShortHelp: "Configure kubeconfig to use Tailscale",
|
||||
ShortHelp: "[ALPHA] Connect to a Kubernetes cluster using a Tailscale Auth Proxy",
|
||||
ShortUsage: "kubeconfig <hostname-or-fqdn>",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
Run this command to configure your kubeconfig to use Tailscale for authentication to a Kubernetes cluster.
|
||||
Run this command to configure kubectl to connect to a Kubernetes cluster over Tailscale.
|
||||
|
||||
The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster.
|
||||
|
||||
See: https://tailscale.com/s/k8s-auth-proxy
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("kubeconfig")
|
||||
|
||||
@@ -35,13 +35,13 @@ var configureHostCmd = &ffcli.Command{
|
||||
var synologyConfigureCmd = &ffcli.Command{
|
||||
Name: "synology",
|
||||
Exec: runConfigureSynology,
|
||||
ShortHelp: "Configure Synology to enable more Tailscale features",
|
||||
ShortHelp: "Configure Synology to enable outbound connections",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
The 'configure-host' command is intended to run at boot as root
|
||||
to create the /dev/net/tun device and give the tailscaled binary
|
||||
permission to use it.
|
||||
This command is intended to run at boot as root on a Synology device to
|
||||
create the /dev/net/tun device and give the tailscaled binary permission
|
||||
to use it.
|
||||
|
||||
See: https://tailscale.com/kb/1152/synology-outbound/
|
||||
See: https://tailscale.com/s/synology-outbound
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("synology")
|
||||
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
var configureCmd = &ffcli.Command{
|
||||
Name: "configure",
|
||||
ShortHelp: "Configure the host to enable more Tailscale features",
|
||||
ShortHelp: "[ALPHA] Configure the host to enable more Tailscale features",
|
||||
LongHelp: strings.TrimSpace(`
|
||||
The 'configure' command is intended to provide a way to configure different
|
||||
services on the host to enable more Tailscale features.
|
||||
The 'configure' set of commands are intended to provide a way to enable different
|
||||
services on the host to use Tailscale in more ways.
|
||||
`),
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("configure")
|
||||
|
||||
138
cmd/tailscale/cli/funnel.go
Normal file
138
cmd/tailscale/cli/funnel.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient})
|
||||
|
||||
// newFunnelCommand returns a new "funnel" subcommand using e as its environment.
|
||||
// The funnel subcommand is used to turn on/off the Funnel service.
|
||||
// Funnel is off by default.
|
||||
// Funnel allows you to publish a 'tailscale serve' server publicly, open to the
|
||||
// entire internet.
|
||||
// newFunnelCommand shares the same serveEnv as the "serve" subcommand. See
|
||||
// newServeCommand and serve.go for more details.
|
||||
func newFunnelCommand(e *serveEnv) *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "funnel",
|
||||
ShortHelp: "Turn on/off Funnel service",
|
||||
ShortUsage: strings.TrimSpace(`
|
||||
funnel <serve-port> {on|off}
|
||||
funnel status [--json]
|
||||
`),
|
||||
LongHelp: strings.Join([]string{
|
||||
"Funnel allows you to publish a 'tailscale serve'",
|
||||
"server publicly, open to the entire internet.",
|
||||
"",
|
||||
"Turning off Funnel only turns off serving to the internet.",
|
||||
"It does not affect serving to your tailnet.",
|
||||
}, "\n"),
|
||||
Exec: e.runFunnel,
|
||||
UsageFunc: usageFunc,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Exec: e.runServeStatus,
|
||||
ShortHelp: "show current serve/funnel status",
|
||||
FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.json, "json", false, "output JSON")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runFunnel is the entry point for the "tailscale funnel" subcommand and
|
||||
// manages turning on/off funnel. Funnel is off by default.
|
||||
//
|
||||
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
|
||||
func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
|
||||
if len(args) != 2 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
var on bool
|
||||
switch args[1] {
|
||||
case "on", "off":
|
||||
on = args[1] == "on"
|
||||
default:
|
||||
return flag.ErrHelp
|
||||
}
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
|
||||
port64, err := strconv.ParseUint(args[0], 10, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port := uint16(port64)
|
||||
|
||||
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
|
||||
if on == sc.AllowFunnel[hp] {
|
||||
printFunnelWarning(sc)
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
if on {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
// clear map mostly for testing
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
}
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
printFunnelWarning(sc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printFunnelWarning prints a warning if the Funnel is on but there is no serve
|
||||
// config for its host:port.
|
||||
func printFunnelWarning(sc *ipn.ServeConfig) {
|
||||
var warn bool
|
||||
for hp, a := range sc.AllowFunnel {
|
||||
if !a {
|
||||
continue
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(string(hp))
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
if _, ok := sc.TCP[uint16(p)]; !ok {
|
||||
warn = true
|
||||
fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp)
|
||||
}
|
||||
}
|
||||
if warn {
|
||||
fmt.Fprintf(os.Stderr, " run: `tailscale serve --help` to see how to configure handlers\n")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mattn/go-isatty"
|
||||
@@ -40,7 +41,16 @@ var netlockCmd = &ffcli.Command{
|
||||
nlLogCmd,
|
||||
nlLocalDisableCmd,
|
||||
},
|
||||
Exec: runNetworkLockStatus,
|
||||
Exec: runNetworkLockNoSubcommand,
|
||||
}
|
||||
|
||||
func runNetworkLockNoSubcommand(ctx context.Context, args []string) error {
|
||||
// Detect & handle the deprecated command 'lock tskey-wrap'.
|
||||
if len(args) >= 2 && args[0] == "tskey-wrap" {
|
||||
return runTskeyWrapCmd(ctx, args[1:])
|
||||
}
|
||||
|
||||
return runNetworkLockStatus(ctx, args)
|
||||
}
|
||||
|
||||
var nlInitArgs struct {
|
||||
@@ -230,6 +240,15 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
|
||||
if k.Key == st.PublicKey {
|
||||
line.WriteString("(self)")
|
||||
}
|
||||
if k.Metadata["purpose"] == "pre-auth key" {
|
||||
if preauthKeyID := k.Metadata["authkey_stableid"]; preauthKeyID != "" {
|
||||
line.WriteString("(pre-auth key ")
|
||||
line.WriteString(preauthKeyID)
|
||||
line.WriteString(")")
|
||||
} else {
|
||||
line.WriteString("(pre-auth key)")
|
||||
}
|
||||
}
|
||||
fmt.Println(line.String())
|
||||
}
|
||||
}
|
||||
@@ -245,11 +264,13 @@ func runNetworkLockStatus(ctx context.Context, args []string) error {
|
||||
for i, addr := range p.TailscaleIPs {
|
||||
line.WriteString(addr.String())
|
||||
if i < len(p.TailscaleIPs)-1 {
|
||||
line.WriteString(", ")
|
||||
line.WriteString(",")
|
||||
}
|
||||
}
|
||||
line.WriteString("\t")
|
||||
line.WriteString(string(p.StableID))
|
||||
line.WriteString("\t")
|
||||
line.WriteString(p.NodeKey.String())
|
||||
fmt.Println(line.String())
|
||||
}
|
||||
}
|
||||
@@ -414,13 +435,19 @@ func runNetworkLockModify(ctx context.Context, addArgs, removeArgs []string) err
|
||||
|
||||
var nlSignCmd = &ffcli.Command{
|
||||
Name: "sign",
|
||||
ShortUsage: "sign <node-key> [<rotation-key>]",
|
||||
ShortHelp: "Signs a node key and transmits the signature to the coordination server",
|
||||
LongHelp: "Signs a node key and transmits the signature to the coordination server",
|
||||
Exec: runNetworkLockSign,
|
||||
ShortUsage: "sign <node-key> [<rotation-key>] or sign <auth-key>",
|
||||
ShortHelp: "Signs a node or pre-approved auth key",
|
||||
LongHelp: `Either:
|
||||
- signs a node key and transmits the signature to the coordination server, or
|
||||
- signs a pre-approved auth key, printing it in a form that can be used to bring up nodes under tailnet lock`,
|
||||
Exec: runNetworkLockSign,
|
||||
}
|
||||
|
||||
func runNetworkLockSign(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 && strings.HasPrefix(args[0], "tskey-auth-") {
|
||||
return runTskeyWrapCmd(ctx, args)
|
||||
}
|
||||
|
||||
var (
|
||||
nodeKey key.NodePublic
|
||||
rotationKey key.NLPublic
|
||||
@@ -622,3 +649,56 @@ func runNetworkLockLog(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTskeyWrapCmd(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: lock tskey-wrap <tailscale pre-auth key>")
|
||||
}
|
||||
if strings.Contains(args[0], "--TL") {
|
||||
return errors.New("Error: provided key was already wrapped")
|
||||
}
|
||||
|
||||
st, err := localClient.StatusWithoutPeers(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
|
||||
return wrapAuthKey(ctx, args[0], st)
|
||||
}
|
||||
|
||||
func wrapAuthKey(ctx context.Context, keyStr string, status *ipnstate.Status) error {
|
||||
// Generate a separate tailnet-lock key just for the credential signature.
|
||||
// We use the free-form meta strings to mark a little bit of metadata about this
|
||||
// key.
|
||||
priv := key.NewNLPrivate()
|
||||
m := map[string]string{
|
||||
"purpose": "pre-auth key",
|
||||
"wrapper_stableid": string(status.Self.ID),
|
||||
"wrapper_createtime": fmt.Sprint(time.Now().Unix()),
|
||||
}
|
||||
if strings.HasPrefix(keyStr, "tskey-auth-") && strings.Index(keyStr[len("tskey-auth-"):], "-") > 0 {
|
||||
// We don't want to accidentally embed the nonce part of the authkey in
|
||||
// the event the format changes. As such, we make sure its in the format we
|
||||
// expect (tskey-auth-<stableID, inc CNTRL suffix>-nonce) before we parse
|
||||
// out and embed the stableID.
|
||||
s := strings.TrimPrefix(keyStr, "tskey-auth-")
|
||||
m["authkey_stableid"] = s[:strings.Index(s, "-")]
|
||||
}
|
||||
k := tka.Key{
|
||||
Kind: tka.Key25519,
|
||||
Public: priv.Public().Verifier(),
|
||||
Votes: 1,
|
||||
Meta: m,
|
||||
}
|
||||
|
||||
wrapped, err := localClient.NetworkLockWrapPreauthKey(ctx, keyStr, priv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrapping failed: %w", err)
|
||||
}
|
||||
if err := localClient.NetworkLockModify(ctx, []tka.Key{k}, nil); err != nil {
|
||||
return fmt.Errorf("add key failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(wrapped)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/mak"
|
||||
"tailscale.com/version"
|
||||
)
|
||||
@@ -35,80 +33,59 @@ var serveCmd = newServeCommand(&serveEnv{lc: &localClient})
|
||||
func newServeCommand(e *serveEnv) *ffcli.Command {
|
||||
return &ffcli.Command{
|
||||
Name: "serve",
|
||||
ShortHelp: "[ALPHA] Serve from your Tailscale node",
|
||||
ShortHelp: "Serve content and local servers",
|
||||
ShortUsage: strings.TrimSpace(`
|
||||
serve [flags] <mount-point> {proxy|path|text} <arg>
|
||||
serve [flags] <sub-command> [sub-flags] <args>`),
|
||||
serve https:<port> <mount-point> <source> [off]
|
||||
serve tcp:<port> tcp://localhost:<local-port> [off]
|
||||
serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
|
||||
serve status [--json]
|
||||
`),
|
||||
LongHelp: strings.TrimSpace(`
|
||||
*** ALPHA; all of this is subject to change ***
|
||||
*** BETA; all of this is subject to change ***
|
||||
|
||||
The 'tailscale serve' set of commands allows you to serve
|
||||
content and local servers from your Tailscale node to
|
||||
your tailnet.
|
||||
your tailnet.
|
||||
|
||||
You can also choose to enable the Tailscale Funnel with:
|
||||
'tailscale serve funnel on'. Funnel allows you to publish
|
||||
'tailscale funnel on'. Funnel allows you to publish
|
||||
a 'tailscale serve' server publicly, open to the entire
|
||||
internet. See https://tailscale.com/funnel.
|
||||
|
||||
EXAMPLES
|
||||
- To proxy requests to a web server at 127.0.0.1:3000:
|
||||
$ tailscale serve / proxy 3000
|
||||
$ tailscale serve https:443 / http://127.0.0.1:3000
|
||||
|
||||
Or, using the default port:
|
||||
$ tailscale serve https / http://127.0.0.1:3000
|
||||
|
||||
- To serve a single file or a directory of files:
|
||||
$ tailscale serve / path /home/alice/blog/index.html
|
||||
$ tailscale serve /images/ path /home/alice/blog/images
|
||||
$ tailscale serve https / /home/alice/blog/index.html
|
||||
$ tailscale serve https /images/ /home/alice/blog/images
|
||||
|
||||
- To serve simple static text:
|
||||
$ tailscale serve / text "Hello, world!"
|
||||
$ tailscale serve https:8080 / text:"Hello, world!"
|
||||
|
||||
- To forward incoming TCP connections on port 2222 to a local TCP server on
|
||||
port 22 (e.g. to run OpenSSH in parallel with Tailscale SSH):
|
||||
$ tailscale serve tcp:2222 tcp://localhost:22
|
||||
|
||||
- To accept TCP TLS connections (terminated within tailscaled) proxied to a
|
||||
local plaintext server on port 80:
|
||||
$ tailscale serve tls-terminated-tcp:443 tcp://localhost:80
|
||||
`),
|
||||
Exec: e.runServe,
|
||||
FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config")
|
||||
fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)")
|
||||
}),
|
||||
Exec: e.runServe,
|
||||
UsageFunc: usageFunc,
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "status",
|
||||
Exec: e.runServeStatus,
|
||||
ShortHelp: "show current serve status",
|
||||
ShortHelp: "show current serve/funnel status",
|
||||
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.json, "json", false, "output JSON")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "tcp",
|
||||
Exec: e.runServeTCP,
|
||||
ShortHelp: "add or remove a TCP port forward",
|
||||
LongHelp: strings.Join([]string{
|
||||
"EXAMPLES",
|
||||
" - Forward TLS over TCP to a local TCP server on port 5432:",
|
||||
" $ tailscale serve tcp 5432",
|
||||
"",
|
||||
" - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:",
|
||||
" $ tailscale serve tcp --terminate-tls 5432",
|
||||
}, "\n"),
|
||||
FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) {
|
||||
fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection")
|
||||
}),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
{
|
||||
Name: "funnel",
|
||||
Exec: e.runServeFunnel,
|
||||
ShortUsage: "funnel [flags] {on|off}",
|
||||
ShortHelp: "turn Tailscale Funnel on or off",
|
||||
LongHelp: strings.Join([]string{
|
||||
"Funnel allows you to publish a 'tailscale serve'",
|
||||
"server publicly, open to the entire internet.",
|
||||
"",
|
||||
"Turning off Funnel only turns off serving to the internet.",
|
||||
"It does not affect serving to your tailnet.",
|
||||
}, "\n"),
|
||||
UsageFunc: usageFunc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -145,10 +122,7 @@ type localServeClient interface {
|
||||
// It also contains the flags, as registered with newServeCommand.
|
||||
type serveEnv struct {
|
||||
// flags
|
||||
servePort uint // Port to serve on. Defaults to 443.
|
||||
terminateTLS bool
|
||||
remove bool // remove a serve config
|
||||
json bool // output JSON (status only for now)
|
||||
json bool // output JSON (status only for now)
|
||||
|
||||
lc localServeClient // localClient interface, specific to serve
|
||||
|
||||
@@ -188,28 +162,15 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status,
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// validateServePort returns --serve-port flag value,
|
||||
// or an error if the port is not a valid port to serve on.
|
||||
func (e *serveEnv) validateServePort() (port uint16, err error) {
|
||||
// make sure e.servePort is uint16
|
||||
port = uint16(e.servePort)
|
||||
if uint(port) != e.servePort {
|
||||
return 0, fmt.Errorf("serve-port %d is out of range", e.servePort)
|
||||
}
|
||||
// make sure e.servePort is 443, 8443 or 10000
|
||||
if port != 443 && port != 8443 && port != 10000 {
|
||||
return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", e.servePort)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// runServe is the entry point for the "serve" subcommand, managing Web
|
||||
// serve config types like proxy, path, and text.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve / proxy 3000
|
||||
// - tailscale serve /images/ path /var/www/images/
|
||||
// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!"
|
||||
// - tailscale serve https / http://localhost:3000
|
||||
// - tailscale serve https /images/ /var/www/images/
|
||||
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
|
||||
// - tailscale serve tcp:2222 tcp://localhost:22
|
||||
// - tailscale serve tls-terminated-tcp:443 tcp://localhost:80
|
||||
func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return flag.ErrHelp
|
||||
@@ -229,39 +190,94 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
return e.lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
|
||||
if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
|
||||
parsePort := func(portStr string) (uint16, error) {
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint16(port64), nil
|
||||
}
|
||||
|
||||
srcType, srcPortStr, found := strings.Cut(args[0], ":")
|
||||
if !found {
|
||||
if srcType == "https" && srcPortStr == "" {
|
||||
// Default https port to 443.
|
||||
srcPortStr = "443"
|
||||
} else {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
}
|
||||
|
||||
turnOff := "off" == args[len(args)-1]
|
||||
|
||||
if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
|
||||
mount, err := cleanMountPoint(args[0])
|
||||
srcPort, err := parsePort(srcPortStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.remove {
|
||||
return e.handleWebServeRemove(ctx, mount)
|
||||
switch srcType {
|
||||
case "https":
|
||||
mount, err := cleanMountPoint(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if turnOff {
|
||||
return e.handleWebServeRemove(ctx, srcPort, mount)
|
||||
}
|
||||
return e.handleWebServe(ctx, srcPort, mount, args[2])
|
||||
case "tcp", "tls-terminated-tcp":
|
||||
if turnOff {
|
||||
return e.handleTCPServeRemove(ctx, srcPort)
|
||||
}
|
||||
return e.handleTCPServe(ctx, srcType, srcPort, args[1])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType)
|
||||
fmt.Fprint(os.Stderr, "must be one of: https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebServe handles the "tailscale serve https:..." subcommand.
|
||||
// It configures the serve config to forward HTTPS connections to the
|
||||
// given source.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve https / http://localhost:3000
|
||||
// - tailscale serve https:8443 /files/ /home/alice/shared-files/
|
||||
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
|
||||
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error {
|
||||
h := new(ipn.HTTPHandler)
|
||||
|
||||
switch args[1] {
|
||||
case "path":
|
||||
ts, _, _ := strings.Cut(source, ":")
|
||||
switch {
|
||||
case ts == "text":
|
||||
text := strings.TrimPrefix(source, "text:")
|
||||
if text == "" {
|
||||
return errors.New("unable to serve; text cannot be an empty string")
|
||||
}
|
||||
h.Text = text
|
||||
case isProxyTarget(source):
|
||||
t, err := expandProxyTarget(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Proxy = t
|
||||
default: // assume path
|
||||
if version.IsSandboxedMacOS() {
|
||||
// don't allow path serving for now on macOS (2022-11-15)
|
||||
return fmt.Errorf("path serving is not supported if sandboxed on macOS")
|
||||
}
|
||||
if !filepath.IsAbs(args[2]) {
|
||||
if !filepath.IsAbs(source) {
|
||||
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
fi, err := os.Stat(args[2])
|
||||
source = filepath.Clean(source)
|
||||
fi, err := os.Stat(source)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
|
||||
return flag.ErrHelp
|
||||
@@ -271,21 +287,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
// for relative file links to work
|
||||
mount += "/"
|
||||
}
|
||||
h.Path = args[2]
|
||||
case "proxy":
|
||||
t, err := expandProxyTarget(args[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.Proxy = t
|
||||
case "text":
|
||||
if args[2] == "" {
|
||||
return errors.New("unable to serve; text cannot be an empty string")
|
||||
}
|
||||
h.Text = args[2]
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
|
||||
return flag.ErrHelp
|
||||
h.Path = source
|
||||
}
|
||||
|
||||
cursc, err := e.lc.GetServeConfig(ctx)
|
||||
@@ -300,7 +302,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
|
||||
@@ -339,12 +341,36 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
// isProxyTarget reports whether source is a valid proxy target.
|
||||
func isProxyTarget(source string) bool {
|
||||
if strings.HasPrefix(source, "http://") ||
|
||||
strings.HasPrefix(source, "https://") ||
|
||||
strings.HasPrefix(source, "https+insecure://") {
|
||||
return true
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
// support "localhost:3000", for example
|
||||
_, portStr, ok := strings.Cut(source, ":")
|
||||
if ok && allNumeric(portStr) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allNumeric reports whether s only comprises of digits
|
||||
// and has at least one digit.
|
||||
func allNumeric(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// handleWebServeRemove removes a web handler from the serve config.
|
||||
// The srvPort argument is the serving port and the mount argument is
|
||||
// the mount point or registered path to remove.
|
||||
func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mount string) error {
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -359,9 +385,9 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error
|
||||
if sc.IsTCPForwardingOnPort(srvPort) {
|
||||
return errors.New("cannot remove web handler; currently serving TCP")
|
||||
}
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
||||
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
|
||||
if !sc.WebHandlerExists(hp, mount) {
|
||||
return errors.New("error: serve config does not exist")
|
||||
return errors.New("error: handler does not exist")
|
||||
}
|
||||
// delete existing handler, then cascade delete if empty
|
||||
delete(sc.Web[hp].Handlers, mount)
|
||||
@@ -396,18 +422,11 @@ func cleanMountPoint(mount string) (string, error) {
|
||||
return "", fmt.Errorf("invalid mount point %q", mount)
|
||||
}
|
||||
|
||||
func expandProxyTarget(target string) (string, error) {
|
||||
if allNumeric(target) {
|
||||
p, err := strconv.ParseUint(target, 10, 16)
|
||||
if p == 0 || err != nil {
|
||||
return "", fmt.Errorf("invalid port %q", target)
|
||||
}
|
||||
return "http://127.0.0.1:" + target, nil
|
||||
func expandProxyTarget(source string) (string, error) {
|
||||
if !strings.Contains(source, "://") {
|
||||
source = "http://" + source
|
||||
}
|
||||
if !strings.Contains(target, "://") {
|
||||
target = "http://" + target
|
||||
}
|
||||
u, err := url.ParseRequestURI(target)
|
||||
u, err := url.ParseRequestURI(source)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing url: %w", err)
|
||||
}
|
||||
@@ -417,9 +436,14 @@ func expandProxyTarget(target string) (string, error) {
|
||||
default:
|
||||
return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
|
||||
}
|
||||
|
||||
port, err := strconv.ParseUint(u.Port(), 10, 16)
|
||||
if port == 0 || err != nil {
|
||||
return "", fmt.Errorf("invalid port %q: %w", u.Port(), err)
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
// TODO(shayne,bradfitz): do we want to do this?
|
||||
case "localhost", "127.0.0.1":
|
||||
host = "127.0.0.1"
|
||||
default:
|
||||
@@ -429,19 +453,115 @@ func expandProxyTarget(target string) (string, error) {
|
||||
if u.Port() != "" {
|
||||
url += ":" + u.Port()
|
||||
}
|
||||
url += u.Path
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func allNumeric(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
// handleTCPServe handles the "tailscale serve tls-terminated-tcp:..." subcommand.
|
||||
// It configures the serve config to forward TCP connections to the
|
||||
// given source.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve tcp:2222 tcp://localhost:22
|
||||
// - tailscale serve tls-terminated-tcp:8443 tcp://localhost:8080
|
||||
func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort uint16, dest string) error {
|
||||
var terminateTLS bool
|
||||
switch srcType {
|
||||
case "tcp":
|
||||
terminateTLS = false
|
||||
case "tls-terminated-tcp":
|
||||
terminateTLS = true
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
dstURL, err := url.Parse(dest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1":
|
||||
// ok
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest)
|
||||
fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr)
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
cursc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := cursc.Clone() // nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
fwdAddr := "127.0.0.1:" + dstPortStr
|
||||
|
||||
if sc.IsServingWeb(srcPort) {
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if terminateTLS {
|
||||
sc.TCP[srcPort].TerminateTLS = dnsName
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runServeStatus prints the current serve config.
|
||||
// handleTCPServeRemove removes the TCP forwarding configuration for the
|
||||
// given srvPort, or serving port.
|
||||
func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
|
||||
cursc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := cursc.Clone() // nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
if sc.IsServingWeb(src) {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
|
||||
}
|
||||
if ph := sc.GetTCPPortHandler(src); ph != nil {
|
||||
delete(sc.TCP, src)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
return e.lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
|
||||
// runServeStatus is the entry point for the "serve status"
|
||||
// subcommand and prints the current serve config.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale status
|
||||
@@ -460,6 +580,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
||||
e.stdout().Write(j)
|
||||
return nil
|
||||
}
|
||||
printFunnelStatus(ctx)
|
||||
if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
|
||||
printf("No serve config\n")
|
||||
return nil
|
||||
@@ -478,17 +599,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
||||
printWebStatusTree(sc, hp)
|
||||
printf("\n")
|
||||
}
|
||||
// warn when funnel on without handlers
|
||||
for hp, a := range sc.AllowFunnel {
|
||||
if !a {
|
||||
continue
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(string(hp))
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
if _, ok := sc.TCP[uint16(p)]; !ok {
|
||||
printf("WARNING: funnel=on for %s, but no serve config\n", hp)
|
||||
}
|
||||
}
|
||||
printFunnelWarning(sc)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -572,152 +683,3 @@ func elipticallyTruncate(s string, max int) string {
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
// runServeTCP is the entry point for the "serve tcp" subcommand and
|
||||
// manages the serve config for TCP forwarding.
|
||||
//
|
||||
// Examples:
|
||||
// - tailscale serve tcp 5432
|
||||
// - tailscale serve --serve-port=8443 tcp 4430
|
||||
// - tailscale serve --serve-port=10000 tcp --terminate-tls 8080
|
||||
func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
portStr := args[0]
|
||||
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if p == 0 || err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr)
|
||||
}
|
||||
|
||||
cursc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sc := cursc.Clone() // nil if no config
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
|
||||
fwdAddr := "127.0.0.1:" + portStr
|
||||
|
||||
if sc.IsServingWeb(srvPort) {
|
||||
if e.remove {
|
||||
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", srvPort)
|
||||
}
|
||||
return fmt.Errorf("cannot serve TCP; already serving web on %d", srvPort)
|
||||
}
|
||||
|
||||
if e.remove {
|
||||
if ph := sc.GetTCPPortHandler(srvPort); ph != nil && ph.TCPForward == fwdAddr {
|
||||
delete(sc.TCP, srvPort)
|
||||
// clear map mostly for testing
|
||||
if len(sc.TCP) == 0 {
|
||||
sc.TCP = nil
|
||||
}
|
||||
return e.lc.SetServeConfig(ctx, sc)
|
||||
}
|
||||
return errors.New("error: serve config does not exist")
|
||||
}
|
||||
|
||||
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
|
||||
|
||||
dnsName, err := e.getSelfDNSName(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.terminateTLS {
|
||||
sc.TCP[srvPort].TerminateTLS = dnsName
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(cursc, sc) {
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runServeFunnel is the entry point for the "serve funnel" subcommand and
|
||||
// manages turning on/off funnel. Funnel is off by default.
|
||||
//
|
||||
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
|
||||
func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return flag.ErrHelp
|
||||
}
|
||||
|
||||
srvPort, err := e.validateServePort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srvPortStr := strconv.Itoa(int(srvPort))
|
||||
|
||||
var on bool
|
||||
switch args[0] {
|
||||
case "on", "off":
|
||||
on = args[0] == "on"
|
||||
default:
|
||||
return flag.ErrHelp
|
||||
}
|
||||
sc, err := e.lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sc == nil {
|
||||
sc = new(ipn.ServeConfig)
|
||||
}
|
||||
st, err := e.getLocalClientStatus(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting client status: %w", err)
|
||||
}
|
||||
if err := checkHasAccess(st.Self.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
||||
hp := ipn.HostPort(dnsName + ":" + srvPortStr)
|
||||
if on == sc.AllowFunnel[hp] {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
if on {
|
||||
mak.Set(&sc.AllowFunnel, hp, true)
|
||||
} else {
|
||||
delete(sc.AllowFunnel, hp)
|
||||
// clear map mostly for testing
|
||||
if len(sc.AllowFunnel) == 0 {
|
||||
sc.AllowFunnel = nil
|
||||
}
|
||||
}
|
||||
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkHasAccess checks three things: 1) an invite was used to join the
|
||||
// Funnel alpha; 2) HTTPS is enabled; 3) the node has the "funnel" attribute.
|
||||
// If any of these are false, an error is returned describing the problem.
|
||||
//
|
||||
// The nodeAttrs arg should be the node's Self.Capabilities which should contain
|
||||
// the attribute we're checking for and possibly warning-capabilities for Funnel.
|
||||
func checkHasAccess(nodeAttrs []string) error {
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
|
||||
return errors.New("Funnel not available; an invite is required to join the alpha. See https://tailscale.com/kb/1223/tailscale-funnel/.")
|
||||
}
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) {
|
||||
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/kb/1153/enabling-https/.")
|
||||
}
|
||||
if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) {
|
||||
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/kb/1223/tailscale-funnel/.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -48,30 +49,6 @@ func TestCleanMountPoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHasAccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
caps []string
|
||||
wantErr bool
|
||||
}{
|
||||
{[]string{}, true}, // No "funnel" attribute
|
||||
{[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true},
|
||||
{[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
|
||||
{[]string{tailcfg.NodeAttrFunnel}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := checkHasAccess(tt.caps)
|
||||
switch {
|
||||
case err != nil && tt.wantErr,
|
||||
err == nil && !tt.wantErr:
|
||||
continue
|
||||
case tt.wantErr:
|
||||
t.Fatalf("got no error, want error")
|
||||
case !tt.wantErr:
|
||||
t.Fatalf("got error %v, want no error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeConfigMutations(t *testing.T) {
|
||||
// Stateful mutations, starting from an empty config.
|
||||
type step struct {
|
||||
@@ -80,6 +57,8 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
want *ipn.ServeConfig // non-nil means we want a save of this value
|
||||
wantErr func(error) (badErrMsg string) // nil means no error is wanted
|
||||
line int // line number of addStep call, for error messages
|
||||
|
||||
debugBreak func()
|
||||
}
|
||||
var steps []step
|
||||
add := func(s step) {
|
||||
@@ -90,19 +69,19 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
// funnel
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
command: cmd("funnel 443 on"),
|
||||
want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
command: cmd("funnel 443 on"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel off"),
|
||||
command: cmd("funnel 443 off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel off"),
|
||||
command: cmd("funnel 443 off"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
@@ -113,27 +92,23 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
// https
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy 0"), // invalid port, too low
|
||||
command: cmd("https:443 / http://localhost:0"), // invalid port, too low
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy 65536"), // invalid port, too high
|
||||
command: cmd("https:443 / http://localhost:65536"), // invalid port, too high
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy somehost"), // invalid host
|
||||
command: cmd("https:443 / http://somehost:3000"), // invalid host
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy http://otherhost"), // invalid host
|
||||
command: cmd("https:443 / httpz://127.0.0.1"), // invalid scheme
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ proxy 3000"),
|
||||
add(step{ // allow omitting port (default to 443)
|
||||
command: cmd("https / http://localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -143,12 +118,33 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // invalid port
|
||||
command: cmd("--serve-port=9999 /abc proxy 3001"),
|
||||
wantErr: anyErr(),
|
||||
add(step{ // support non Funnel port
|
||||
command: cmd("https:9999 /abc http://localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {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"},
|
||||
}},
|
||||
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
|
||||
"/abc": {Proxy: "http://127.0.0.1:3001"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--serve-port=8443 /abc proxy 3001"),
|
||||
command: cmd("https:9999 /abc off"),
|
||||
want: &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"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("https:8443 /abc http://127.0.0.1:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -162,7 +158,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--serve-port=10000 / text hi"),
|
||||
command: cmd("https:10000 / text:hi"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}},
|
||||
@@ -180,12 +176,12 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /foo"),
|
||||
command: cmd("https:443 /foo off"),
|
||||
want: nil, // nothing to save
|
||||
wantErr: anyErr(),
|
||||
}) // handler doesn't exist, so we get an error
|
||||
add(step{
|
||||
command: cmd("--remove --serve-port=10000 /"),
|
||||
command: cmd("https:10000 / off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -199,7 +195,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /"),
|
||||
command: cmd("https:443 / off"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -210,11 +206,11 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove --serve-port=8443 /abc"),
|
||||
command: cmd("https:8443 /abc off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("bar proxy https://127.0.0.1:8443"),
|
||||
add(step{ // clean mount: "bar" becomes "/bar"
|
||||
command: cmd("https:443 bar https://127.0.0.1:8443"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -225,12 +221,12 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("bar proxy https://127.0.0.1:8443"),
|
||||
command: cmd("https:443 bar https://127.0.0.1:8443"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy https+insecure://127.0.0.1:3001"),
|
||||
command: cmd("https:443 / https+insecure://127.0.0.1:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -242,7 +238,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/foo proxy localhost:3000"),
|
||||
command: cmd("https:443 /foo localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -253,7 +249,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // test a second handler on the same port
|
||||
command: cmd("--serve-port=8443 /foo proxy localhost:3000"),
|
||||
command: cmd("https:8443 /foo localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -266,19 +262,50 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // support path in proxy
|
||||
command: cmd("https / http://127.0.0.1:3000/foo/bar"),
|
||||
want: &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/foo/bar"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// tcp
|
||||
add(step{reset: true})
|
||||
add(step{ // must include scheme for tcp
|
||||
command: cmd("tls-terminated-tcp:443 localhost:5432"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{ // !somehost, must be localhost or 127.0.0.1
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{ // bad target port, too low
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:0"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{ // bad target port, too high
|
||||
command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp 5432"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:5432"},
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls 8443"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
@@ -289,11 +316,11 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls 8443"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"),
|
||||
want: nil, // nothing to save
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp --terminate-tls 8444"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:8444"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {
|
||||
@@ -304,35 +331,41 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("tcp -terminate-tls=false 8445"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8445"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:8445"},
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:8445",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("tcp 123"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:123"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:123"},
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:123",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove tcp 321"),
|
||||
add(step{ // handler doesn't exist, so we get an error
|
||||
command: cmd("tls-terminated-tcp:8443 off"),
|
||||
wantErr: anyErr(),
|
||||
}) // handler doesn't exist, so we get an error
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove tcp 123"),
|
||||
command: cmd("tls-terminated-tcp:443 off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// text
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ text hello"),
|
||||
command: cmd("https:443 / text:hello"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -353,7 +386,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
add(step{reset: true})
|
||||
writeFile("foo", "this is foo")
|
||||
add(step{
|
||||
command: cmd("/ path " + filepath.Join(td, "foo")),
|
||||
command: cmd("https:443 / " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -366,7 +399,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
|
||||
writeFile("subdir/file-a", "this is A")
|
||||
add(step{
|
||||
command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")),
|
||||
command: cmd("https:443 /some/where " + filepath.Join(td, "subdir/file-a")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -377,13 +410,13 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("/ path missing"),
|
||||
add(step{ // bad path
|
||||
command: cmd("https:443 / bad/path"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ path " + filepath.Join(td, "subdir")),
|
||||
command: cmd("https:443 / " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -394,14 +427,14 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("--remove /"),
|
||||
command: cmd("https:443 / off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// combos
|
||||
add(step{reset: true})
|
||||
add(step{
|
||||
command: cmd("/ proxy 3000"),
|
||||
command: cmd("https:443 / localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -412,7 +445,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{
|
||||
command: cmd("funnel on"),
|
||||
command: cmd("funnel 443 on"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -424,7 +457,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // serving on secondary port doesn't change funnel
|
||||
command: cmd("--serve-port=8443 /bar proxy 3001"),
|
||||
command: cmd("https:8443 /bar localhost:3001"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
@@ -439,7 +472,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // turn funnel on for secondary port
|
||||
command: cmd("--serve-port=8443 funnel on"),
|
||||
command: cmd("funnel 8443 on"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
@@ -454,7 +487,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // turn funnel off for primary port 443
|
||||
command: cmd("funnel off"),
|
||||
command: cmd("funnel 443 off"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
|
||||
@@ -469,7 +502,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // remove secondary port
|
||||
command: cmd("--serve-port=8443 --remove /bar"),
|
||||
command: cmd("https:8443 /bar off"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
@@ -481,7 +514,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // start a tcp forwarder on 8443
|
||||
command: cmd("--serve-port=8443 tcp 5432"),
|
||||
command: cmd("tcp:8443 tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
@@ -493,27 +526,27 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // remove primary port http handler
|
||||
command: cmd("--remove /"),
|
||||
command: cmd("https:443 / off"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
|
||||
},
|
||||
})
|
||||
add(step{ // remove tcp forwarder
|
||||
command: cmd("--serve-port=8443 --remove tcp 5432"),
|
||||
command: cmd("tls-terminated-tcp:8443 off"),
|
||||
want: &ipn.ServeConfig{
|
||||
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
|
||||
},
|
||||
})
|
||||
add(step{ // turn off funnel
|
||||
command: cmd("--serve-port=8443 funnel off"),
|
||||
command: cmd("funnel 8443 off"),
|
||||
want: &ipn.ServeConfig{},
|
||||
})
|
||||
|
||||
// tricky steps
|
||||
add(step{reset: true})
|
||||
add(step{ // a directory with a trailing slash mount point
|
||||
command: cmd("/dir path " + filepath.Join(td, "subdir")),
|
||||
command: cmd("https:443 /dir " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -524,7 +557,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("/dir path " + filepath.Join(td, "foo")),
|
||||
command: cmd("https:443 /dir " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -536,7 +569,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
})
|
||||
add(step{reset: true}) // reset and do the opposite
|
||||
add(step{ // a file without a trailing slash mount point
|
||||
command: cmd("/dir path " + filepath.Join(td, "foo")),
|
||||
command: cmd("https:443 /dir " + filepath.Join(td, "foo")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -547,7 +580,7 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
})
|
||||
add(step{ // this should overwrite the previous one
|
||||
command: cmd("/dir path " + filepath.Join(td, "subdir")),
|
||||
command: cmd("https:443 /dir " + filepath.Join(td, "subdir")),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -560,37 +593,24 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
|
||||
// error states
|
||||
add(step{reset: true})
|
||||
add(step{ // make sure we can't add "tcp" as if it was a mount
|
||||
command: cmd("tcp text foo"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{ // "/tcp" is fine though as a mount
|
||||
command: cmd("/tcp text foo"),
|
||||
want: &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{
|
||||
"/tcp": {Text: "foo"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // tcp forward 5432 on serve port 443
|
||||
command: cmd("tcp 5432"),
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{
|
||||
443: {TCPForward: "127.0.0.1:5432"},
|
||||
443: {
|
||||
TCPForward: "127.0.0.1:5432",
|
||||
TerminateTLS: "foo.test.ts.net",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a web handler on the same port
|
||||
command: cmd("/ proxy 3000"),
|
||||
command: cmd("https:443 / localhost:3000"),
|
||||
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
|
||||
})
|
||||
add(step{reset: true})
|
||||
add(step{ // start a web handler on port 443
|
||||
command: cmd("/ proxy 3000"),
|
||||
command: cmd("https:443 / localhost:3000"),
|
||||
want: &ipn.ServeConfig{
|
||||
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
|
||||
Web: map[ipn.HostPort]*ipn.WebServerConfig{
|
||||
@@ -600,14 +620,17 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
add(step{ // try to start a tcp forwarder on the same serve port (443 default)
|
||||
command: cmd("tcp 5432"),
|
||||
add(step{ // try to start a tcp forwarder on the same serve port
|
||||
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
|
||||
wantErr: anyErr(),
|
||||
})
|
||||
|
||||
lc := &fakeLocalServeClient{}
|
||||
// And now run the steps above.
|
||||
for i, st := range steps {
|
||||
if st.debugBreak != nil {
|
||||
st.debugBreak()
|
||||
}
|
||||
if st.reset {
|
||||
t.Logf("Executing step #%d, line %v: [reset]", i, st.line)
|
||||
lc.config = nil
|
||||
@@ -625,8 +648,16 @@ func TestServeConfigMutations(t *testing.T) {
|
||||
testStdout: &stdout,
|
||||
}
|
||||
lastCount := lc.setCount
|
||||
cmd := newServeCommand(e)
|
||||
err := cmd.ParseAndRun(context.Background(), st.command)
|
||||
var cmd *ffcli.Command
|
||||
var args []string
|
||||
if st.command[0] == "funnel" {
|
||||
cmd = newFunnelCommand(e)
|
||||
args = st.command[1:]
|
||||
} else {
|
||||
cmd = newServeCommand(e)
|
||||
args = st.command
|
||||
}
|
||||
err := cmd.ParseAndRun(context.Background(), args)
|
||||
if flagOut.Len() > 0 {
|
||||
t.Logf("flag package output: %q", flagOut.Bytes())
|
||||
}
|
||||
@@ -677,7 +708,7 @@ var fakeStatus = &ipnstate.Status{
|
||||
BackendState: ipn.Running.String(),
|
||||
Self: &ipnstate.PeerStatus{
|
||||
DNSName: "foo.test.ts.net",
|
||||
Capabilities: []string{tailcfg.NodeAttrFunnel},
|
||||
Capabilities: []string{tailcfg.NodeAttrFunnel, tailcfg.CapabilityFunnelPorts + "?ports=443,8443"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -717,7 +748,5 @@ func anyErr() func(error) string {
|
||||
}
|
||||
|
||||
func cmd(s string) []string {
|
||||
cmds := strings.Fields(s)
|
||||
fmt.Printf("cmd: %v", cmds)
|
||||
return cmds
|
||||
return strings.Fields(s)
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@ func printFunnelStatus(ctx context.Context) {
|
||||
}
|
||||
printf("# - %s\n", url)
|
||||
}
|
||||
outln()
|
||||
}
|
||||
|
||||
// isRunningOrStarting reports whether st is in state Running or Starting.
|
||||
|
||||
@@ -145,11 +145,11 @@ func newUpdater() (*updater, error) {
|
||||
case strings.HasSuffix(os.Getenv("HOME"), "/io.tailscale.ipn.macsys/Data"):
|
||||
up.update = up.updateMacSys
|
||||
default:
|
||||
return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/kb/1083/install-unstable/ to use TestFlight or to install the non-App Store version")
|
||||
return nil, errors.New("This is the macOS App Store version of Tailscale; update in the App Store, or see https://tailscale.com/s/unstable-clients to use TestFlight or to install the non-App Store version")
|
||||
}
|
||||
}
|
||||
if up.update == nil {
|
||||
return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/kb/1067/update/")
|
||||
return nil, errors.New("The 'update' command is not supported on this platform; see https://tailscale.com/s/client-updates")
|
||||
}
|
||||
return up, nil
|
||||
}
|
||||
|
||||
@@ -86,10 +86,9 @@ func TestQnapAuthnURL(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "err != nil",
|
||||
in: "http://192.168.0.%31/",
|
||||
in: "http://192.168.0.%31/",
|
||||
want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token",
|
||||
},
|
||||
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -212,17 +212,18 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+
|
||||
tailscale.com/ipn/localapi from tailscale.com/ipn/ipnserver
|
||||
tailscale.com/ipn/policy from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/ipn/store from tailscale.com/cmd/tailscaled+
|
||||
L tailscale.com/ipn/store/awsstore from tailscale.com/ipn/store
|
||||
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
|
||||
tailscale.com/ipn/store/mem from tailscale.com/ipn/store+
|
||||
L tailscale.com/kube from tailscale.com/ipn/store/kubestore
|
||||
tailscale.com/log/filelogger from tailscale.com/logpolicy
|
||||
tailscale.com/log/logheap from tailscale.com/control/controlclient
|
||||
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/logtail from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/backoff from tailscale.com/control/controlclient+
|
||||
tailscale.com/logtail/filch from tailscale.com/logpolicy
|
||||
tailscale.com/logtail/filch from tailscale.com/logpolicy+
|
||||
tailscale.com/metrics from tailscale.com/derp+
|
||||
tailscale.com/net/connstats from tailscale.com/net/tstun+
|
||||
tailscale.com/net/dns from tailscale.com/ipn/ipnlocal+
|
||||
@@ -414,7 +415,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
encoding/xml from github.com/tailscale/goupnp+
|
||||
errors from bufio+
|
||||
expvar from tailscale.com/derp+
|
||||
flag from tailscale.com/control/controlclient+
|
||||
flag from net/http/httptest+
|
||||
fmt from compress/flate+
|
||||
hash from crypto+
|
||||
hash/adler32 from tailscale.com/ipn/ipnlocal
|
||||
|
||||
@@ -61,14 +61,14 @@ type Auto struct {
|
||||
|
||||
paused bool // whether we should stop making HTTP requests
|
||||
unpauseWaiters []chan struct{}
|
||||
loggedIn bool // true if currently logged in
|
||||
loginGoal *LoginGoal // non-nil if some login activity is desired
|
||||
synced bool // true if our netmap is up-to-date
|
||||
inPollNetMap bool // true if currently running a PollNetMap
|
||||
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
|
||||
liteMapUpdateCancel func() // cancels a lite map update
|
||||
liteMapUpdateCancels int // how many times we've canceled a lite map update
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
loggedIn bool // true if currently logged in
|
||||
loginGoal *LoginGoal // non-nil if some login activity is desired
|
||||
synced bool // true if our netmap is up-to-date
|
||||
inPollNetMap bool // true if currently running a PollNetMap
|
||||
inLiteMapUpdate bool // true if a lite (non-streaming) map request is outstanding
|
||||
liteMapUpdateCancel context.CancelFunc // cancels a lite map update, may be nil
|
||||
liteMapUpdateCancels int // how many times we've canceled a lite map update
|
||||
inSendStatus int // number of sendStatus calls currently in progress
|
||||
state State
|
||||
|
||||
authCtx context.Context // context used for auth requests
|
||||
@@ -180,14 +180,15 @@ func (c *Auto) sendNewMapRequest() {
|
||||
|
||||
// If we are already in process of doing a LiteMapUpdate, cancel it and
|
||||
// try a new one. If this is the 10th time we have done this
|
||||
// cancelation, tear down everything and start again
|
||||
// cancelation, tear down everything and start again.
|
||||
const maxLiteMapUpdateAttempts = 10
|
||||
if c.inLiteMapUpdate {
|
||||
// Always cancel the in-flight lite map update, regardless of
|
||||
// whether we cancel the streaming map request or not.
|
||||
c.liteMapUpdateCancel()
|
||||
c.inLiteMapUpdate = false
|
||||
|
||||
if c.liteMapUpdateCancels > 10 {
|
||||
if c.liteMapUpdateCancels >= maxLiteMapUpdateAttempts {
|
||||
// Not making progress
|
||||
c.mu.Unlock()
|
||||
c.cancelMapSafely()
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -87,16 +88,15 @@ type Direct struct {
|
||||
sfGroup singleflight.Group[struct{}, *NoiseClient] // protects noiseClient creation.
|
||||
noiseClient *NoiseClient
|
||||
|
||||
persist persist.PersistView
|
||||
authKey string
|
||||
tryingNewKey key.NodePrivate
|
||||
expiry *time.Time
|
||||
hostinfo *tailcfg.Hostinfo // always non-nil
|
||||
netinfo *tailcfg.NetInfo
|
||||
endpoints []tailcfg.Endpoint
|
||||
tkaHead string
|
||||
everEndpoints bool // whether we've ever had non-empty endpoints
|
||||
lastPingURL string // last PingRequest.URL received, for dup suppression
|
||||
persist persist.PersistView
|
||||
authKey string
|
||||
tryingNewKey key.NodePrivate
|
||||
expiry *time.Time
|
||||
hostinfo *tailcfg.Hostinfo // always non-nil
|
||||
netinfo *tailcfg.NetInfo
|
||||
endpoints []tailcfg.Endpoint
|
||||
tkaHead string
|
||||
lastPingURL string // last PingRequest.URL received, for dup suppression
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
@@ -212,6 +212,7 @@ func NewDirect(opts Options) (*Direct, error) {
|
||||
Forward: dnscache.Get().Forward, // use default cache's forwarder
|
||||
UseLastGood: true,
|
||||
LookupIPFallback: dnsfallback.Lookup,
|
||||
Logf: opts.Logf,
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.Proxy = tshttpproxy.ProxyFromEnvironment
|
||||
@@ -424,7 +425,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
tryingNewKey := c.tryingNewKey
|
||||
serverKey := c.serverKey
|
||||
serverNoiseKey := c.serverNoiseKey
|
||||
authKey := c.authKey
|
||||
authKey, isWrapped, wrappedSig, wrappedKey := decodeWrappedAuthkey(c.authKey, c.logf)
|
||||
hi := c.hostInfoLocked()
|
||||
backendLogID := hi.BackendLogID
|
||||
expired := c.expiry != nil && !c.expiry.IsZero() && c.expiry.Before(c.timeNow())
|
||||
@@ -510,6 +511,22 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new
|
||||
if nodeKeySignature, err = resignNKS(persist.NetworkLockKey, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil {
|
||||
c.logf("Failed re-signing node-key signature: %v", err)
|
||||
}
|
||||
} else if isWrapped {
|
||||
// We were given a wrapped pre-auth key, which means that in addition
|
||||
// to being a regular pre-auth key there was a suffix with information to
|
||||
// generate a tailnet-lock signature.
|
||||
nk, err := tryingNewKey.Public().MarshalBinary()
|
||||
if err != nil {
|
||||
return false, "", nil, fmt.Errorf("marshalling node-key: %w", err)
|
||||
}
|
||||
sig := &tka.NodeKeySignature{
|
||||
SigKind: tka.SigRotation,
|
||||
Pubkey: nk,
|
||||
Nested: wrappedSig,
|
||||
}
|
||||
sigHash := sig.SigHash()
|
||||
sig.Signature = ed25519.Sign(wrappedKey, sigHash[:])
|
||||
nodeKeySignature = sig.Serialize()
|
||||
}
|
||||
|
||||
if backendLogID == "" {
|
||||
@@ -735,9 +752,6 @@ func (c *Direct) newEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
}
|
||||
c.logf("[v2] client.newEndpoints(%v)", epStrs)
|
||||
c.endpoints = append(c.endpoints[:0], endpoints...)
|
||||
if len(endpoints) > 0 {
|
||||
c.everEndpoints = true
|
||||
}
|
||||
return true // changed
|
||||
}
|
||||
|
||||
@@ -750,8 +764,6 @@ func (c *Direct) SetEndpoints(endpoints []tailcfg.Endpoint) (changed bool) {
|
||||
return c.newEndpoints(endpoints)
|
||||
}
|
||||
|
||||
func inTest() bool { return flag.Lookup("test.v") != nil }
|
||||
|
||||
// PollNetMap makes a /map request to download the network map, calling cb with
|
||||
// each new netmap.
|
||||
func (c *Direct) PollNetMap(ctx context.Context, cb func(*netmap.NetworkMap)) error {
|
||||
@@ -806,7 +818,6 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
epStrs = append(epStrs, ep.Addr.String())
|
||||
epTypes = append(epTypes, ep.Type)
|
||||
}
|
||||
everEndpoints := c.everEndpoints
|
||||
c.mu.Unlock()
|
||||
|
||||
machinePrivKey, err := c.getMachinePrivKey()
|
||||
@@ -847,15 +858,17 @@ func (c *Direct) sendMapRequest(ctx context.Context, maxPolls int, readOnly bool
|
||||
OmitPeers: cb == nil,
|
||||
TKAHead: c.tkaHead,
|
||||
|
||||
// On initial startup before we know our endpoints, set the ReadOnly flag
|
||||
// to tell the control server not to distribute out our (empty) endpoints to peers.
|
||||
// Presumably we'll learn our endpoints in a half second and do another post
|
||||
// with useful results. The first POST just gets us the DERP map which we
|
||||
// need to do the STUN queries to discover our endpoints.
|
||||
// TODO(bradfitz): we skip this optimization in tests, though,
|
||||
// because the e2e tests are currently hyper-specific about the
|
||||
// ordering of things. The e2e tests need love.
|
||||
ReadOnly: readOnly || (len(epStrs) == 0 && !everEndpoints && !inTest()),
|
||||
// Previously we'd set ReadOnly to true if we didn't have any endpoints
|
||||
// yet as we expected to learn them in a half second and restart the full
|
||||
// streaming map poll, however as we are trying to reduce the number of
|
||||
// times we restart the full streaming map poll we now just set ReadOnly
|
||||
// false when we're doing a full streaming map poll.
|
||||
//
|
||||
// TODO(maisem/bradfitz): really ReadOnly should be set to true if for
|
||||
// all streams and we should only do writes via lite map updates.
|
||||
// However that requires an audit and a bunch of testing to make sure we
|
||||
// don't break anything.
|
||||
ReadOnly: readOnly && !allowStream,
|
||||
}
|
||||
var extraDebugFlags []string
|
||||
if hi != nil && c.linkMon != nil && !c.skipIPForwardingCheck &&
|
||||
@@ -1713,6 +1726,43 @@ func (c *Direct) ReportHealthChange(sys health.Subsystem, sysErr error) {
|
||||
res.Body.Close()
|
||||
}
|
||||
|
||||
// decodeWrappedAuthkey separates wrapping information from an authkey, if any.
|
||||
// In all cases the authkey is returned, sans wrapping information if any.
|
||||
//
|
||||
// If the authkey is wrapped, isWrapped returns true, along with the wrapping signature
|
||||
// and private key.
|
||||
func decodeWrappedAuthkey(key string, logf logger.Logf) (authKey string, isWrapped bool, sig *tka.NodeKeySignature, priv ed25519.PrivateKey) {
|
||||
authKey, suffix, found := strings.Cut(key, "--TL")
|
||||
if !found {
|
||||
return key, false, nil, nil
|
||||
}
|
||||
sigBytes, privBytes, found := strings.Cut(suffix, "-")
|
||||
if !found {
|
||||
logf("decoding wrapped auth-key: did not find delimiter")
|
||||
return key, false, nil, nil
|
||||
}
|
||||
|
||||
rawSig, err := base64.RawStdEncoding.DecodeString(sigBytes)
|
||||
if err != nil {
|
||||
logf("decoding wrapped auth-key: signature decode: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
rawPriv, err := base64.RawStdEncoding.DecodeString(privBytes)
|
||||
if err != nil {
|
||||
logf("decoding wrapped auth-key: priv decode: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
|
||||
sig = new(tka.NodeKeySignature)
|
||||
if err := sig.Unserialize([]byte(rawSig)); err != nil {
|
||||
logf("decoding wrapped auth-key: signature: %v", err)
|
||||
return key, false, nil, nil
|
||||
}
|
||||
priv = ed25519.PrivateKey(rawPriv)
|
||||
|
||||
return authKey, true, sig, priv
|
||||
}
|
||||
|
||||
var (
|
||||
metricMapRequestsActive = clientmetric.NewGauge("controlclient_map_requests_active")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package controlclient
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -142,3 +143,42 @@ func TestTsmpPing(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWrappedAuthkey(t *testing.T) {
|
||||
k, isWrapped, sig, priv := decodeWrappedAuthkey("tskey-32mjsdkdsffds9o87dsfkjlh", nil)
|
||||
if want := "tskey-32mjsdkdsffds9o87dsfkjlh"; k != want {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).key = %q, want %q", k, want)
|
||||
}
|
||||
if isWrapped {
|
||||
t.Error("decodeWrappedAuthkey(<unwrapped-key>).isWrapped = true, want false")
|
||||
}
|
||||
if sig != nil {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).sig = %v, want nil", sig)
|
||||
}
|
||||
if priv != nil {
|
||||
t.Errorf("decodeWrappedAuthkey(<unwrapped-key>).priv = %v, want nil", priv)
|
||||
}
|
||||
|
||||
k, isWrapped, sig, priv = decodeWrappedAuthkey("tskey-auth-k7UagY1CNTRL-ZZZZZ--TLpAEDA1ggnXuw4/fWnNWUwcoOjLemhOvml1juMl5lhLmY5sBUsj8EWEAfL2gdeD9g8VDw5tgcxCiHGlEb67BgU2DlFzZApi4LheLJraA+pYjTGChVhpZz1iyiBPD+U2qxDQAbM3+WFY0EBlggxmVqG53Hu0Rg+KmHJFMlUhfgzo+AQP6+Kk9GzvJJOs4-k36RdoSFqaoARfQo0UncHAV0t3YTqrkD5r/z2jTrE43GZWobnce7RGD4qYckUyVSF+DOj4BA/r4qT0bO8kk6zg", nil)
|
||||
if want := "tskey-auth-k7UagY1CNTRL-ZZZZZ"; k != want {
|
||||
t.Errorf("decodeWrappedAuthkey(<wrapped-key>).key = %q, want %q", k, want)
|
||||
}
|
||||
if !isWrapped {
|
||||
t.Error("decodeWrappedAuthkey(<wrapped-key>).isWrapped = false, want true")
|
||||
}
|
||||
|
||||
if sig == nil {
|
||||
t.Fatal("decodeWrappedAuthkey(<wrapped-key>).sig = nil, want non-nil signature")
|
||||
}
|
||||
sigHash := sig.SigHash()
|
||||
if !ed25519.Verify(sig.KeyID, sigHash[:], sig.Signature) {
|
||||
t.Error("signature failed to verify")
|
||||
}
|
||||
|
||||
// Make sure the private is correct by using it.
|
||||
someSig := ed25519.Sign(priv, []byte{1, 2, 3, 4})
|
||||
if !ed25519.Verify(sig.WrappingPubkey, []byte{1, 2, 3, 4}, someSig) {
|
||||
t.Error("failed to use priv")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -388,12 +388,14 @@ func (a *Dialer) tryURLUpgrade(ctx context.Context, u *url.URL, addr netip.Addr,
|
||||
dns = &dnscache.Resolver{
|
||||
SingleHostStaticResult: []netip.Addr{addr},
|
||||
SingleHost: u.Hostname(),
|
||||
Logf: a.Logf, // not a.logf method; we want to propagate nil-ness
|
||||
}
|
||||
} else {
|
||||
dns = &dnscache.Resolver{
|
||||
Forward: dnscache.Get().Forward,
|
||||
LookupIPFallback: dnsfallback.Lookup,
|
||||
UseLastGood: true,
|
||||
Logf: a.Logf, // not a.logf method; we want to propagate nil-ness
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
fb11c0df588717a3ee13b09dacae1e7093279d67
|
||||
ddff070c02790cb571006e820e58cce9627569cf
|
||||
|
||||
@@ -83,6 +83,9 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
writeJSON(res)
|
||||
case "/sockstats":
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
b.sockstatLogger.WriteLogs(w)
|
||||
default:
|
||||
http.Error(w, "unknown c2n path", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"tailscale.com/atomicfile"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/hostinfo"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/ipn/store"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/version"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -82,11 +85,6 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
||||
return nil, errors.New("invalid domain")
|
||||
}
|
||||
logf := logger.WithPrefix(b.logf, fmt.Sprintf("cert(%q): ", domain))
|
||||
dir, err := b.certDir()
|
||||
if err != nil {
|
||||
logf("failed to get certDir: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
traceACME := func(v any) {
|
||||
if !acmeDebug() {
|
||||
@@ -96,17 +94,22 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
||||
log.Printf("acme %T: %s", v, j)
|
||||
}
|
||||
|
||||
if pair, err := b.getCertPEMCached(dir, domain, now); err == nil {
|
||||
cs, err := b.getCertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pair, err := getCertPEMCached(cs, domain, now); err == nil {
|
||||
future := now.AddDate(0, 0, 14)
|
||||
if b.shouldStartDomainRenewal(dir, domain, future) {
|
||||
if b.shouldStartDomainRenewal(cs, domain, future) {
|
||||
logf("starting async renewal")
|
||||
// Start renewal in the background.
|
||||
go b.getCertPEM(context.Background(), logf, traceACME, dir, domain, future)
|
||||
go b.getCertPEM(context.Background(), cs, logf, traceACME, domain, future)
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
pair, err := b.getCertPEM(ctx, logf, traceACME, dir, domain, now)
|
||||
pair, err := b.getCertPEM(ctx, cs, logf, traceACME, domain, now)
|
||||
if err != nil {
|
||||
logf("getCertPEM: %v", err)
|
||||
return nil, err
|
||||
@@ -114,7 +117,7 @@ func (b *LocalBackend) GetCertPEM(ctx context.Context, domain string) (*TLSCertK
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) shouldStartDomainRenewal(dir, domain string, future time.Time) bool {
|
||||
func (b *LocalBackend) shouldStartDomainRenewal(cs certStore, domain string, future time.Time) bool {
|
||||
renewMu.Lock()
|
||||
defer renewMu.Unlock()
|
||||
now := time.Now()
|
||||
@@ -124,7 +127,7 @@ func (b *LocalBackend) shouldStartDomainRenewal(dir, domain string, future time.
|
||||
return false
|
||||
}
|
||||
lastRenewCheck[domain] = now
|
||||
_, err := b.getCertPEMCached(dir, domain, future)
|
||||
_, err := getCertPEMCached(cs, domain, future)
|
||||
return errors.Is(err, errCertExpired)
|
||||
}
|
||||
|
||||
@@ -140,15 +143,32 @@ type certStore interface {
|
||||
WriteCert(domain string, cert []byte) error
|
||||
// WriteKey writes the key for domain.
|
||||
WriteKey(domain string, key []byte) error
|
||||
// ACMEKey returns the value previously stored via WriteACMEKey.
|
||||
// It is a PEM encoded ECDSA key.
|
||||
ACMEKey() ([]byte, error)
|
||||
// WriteACMEKey stores the provided PEM encoded ECDSA key.
|
||||
WriteACMEKey([]byte) error
|
||||
}
|
||||
|
||||
var errCertExpired = errors.New("cert expired")
|
||||
|
||||
func (b *LocalBackend) getCertStore(dir string) certStore {
|
||||
if hostinfo.GetEnvType() == hostinfo.Kubernetes && dir == "/tmp" {
|
||||
return certStateStore{StateStore: b.store}
|
||||
func (b *LocalBackend) getCertStore() (certStore, error) {
|
||||
switch b.store.(type) {
|
||||
case *store.FileStore:
|
||||
case *mem.Store:
|
||||
default:
|
||||
if hostinfo.GetEnvType() == hostinfo.Kubernetes {
|
||||
// We're running in Kubernetes with a custom StateStore,
|
||||
// use that instead of the cert directory.
|
||||
// TODO(maisem): expand this to other environments?
|
||||
return certStateStore{StateStore: b.store}, nil
|
||||
}
|
||||
}
|
||||
return certFileStore{dir: dir}
|
||||
dir, err := b.certDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return certFileStore{dir: dir}, nil
|
||||
}
|
||||
|
||||
// certFileStore implements certStore by storing the cert & key files in the named directory.
|
||||
@@ -160,6 +180,25 @@ type certFileStore struct {
|
||||
testRoots *x509.CertPool
|
||||
}
|
||||
|
||||
const acmePEMName = "acme-account.key.pem"
|
||||
|
||||
func (f certFileStore) ACMEKey() ([]byte, error) {
|
||||
pemName := filepath.Join(f.dir, acmePEMName)
|
||||
v, err := os.ReadFile(pemName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ipn.ErrStateNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (f certFileStore) WriteACMEKey(b []byte) error {
|
||||
pemName := filepath.Join(f.dir, acmePEMName)
|
||||
return atomicfile.WriteFile(pemName, b, 0600)
|
||||
}
|
||||
|
||||
func (f certFileStore) Read(domain string, now time.Time) (*TLSCertKeyPair, error) {
|
||||
certPEM, err := os.ReadFile(certFile(f.dir, domain))
|
||||
if err != nil {
|
||||
@@ -182,11 +221,11 @@ func (f certFileStore) Read(domain string, now time.Time) (*TLSCertKeyPair, erro
|
||||
}
|
||||
|
||||
func (f certFileStore) WriteCert(domain string, cert []byte) error {
|
||||
return os.WriteFile(certFile(f.dir, domain), cert, 0644)
|
||||
return atomicfile.WriteFile(certFile(f.dir, domain), cert, 0644)
|
||||
}
|
||||
|
||||
func (f certFileStore) WriteKey(domain string, key []byte) error {
|
||||
return os.WriteFile(keyFile(f.dir, domain), key, 0600)
|
||||
return atomicfile.WriteFile(keyFile(f.dir, domain), key, 0600)
|
||||
}
|
||||
|
||||
// certStateStore implements certStore by storing the cert & key files in an ipn.StateStore.
|
||||
@@ -221,6 +260,14 @@ func (s certStateStore) WriteKey(domain string, key []byte) error {
|
||||
return s.WriteState(ipn.StateKey(domain+".key"), key)
|
||||
}
|
||||
|
||||
func (s certStateStore) ACMEKey() ([]byte, error) {
|
||||
return s.ReadState(ipn.StateKey(acmePEMName))
|
||||
}
|
||||
|
||||
func (s certStateStore) WriteACMEKey(key []byte) error {
|
||||
return s.WriteState(ipn.StateKey(acmePEMName), key)
|
||||
}
|
||||
|
||||
// TLSCertKeyPair is a TLS public and private key, and whether they were obtained
|
||||
// from cache or freshly obtained.
|
||||
type TLSCertKeyPair struct {
|
||||
@@ -236,26 +283,26 @@ func certFile(dir, domain string) string { return filepath.Join(dir, domain+".cr
|
||||
// domain exists on disk in dir that is valid at the provided now time.
|
||||
// If the keypair is expired, it returns errCertExpired.
|
||||
// If the keypair doesn't exist, it returns ipn.ErrStateNotExist.
|
||||
func (b *LocalBackend) getCertPEMCached(dir, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
|
||||
func getCertPEMCached(cs certStore, domain string, now time.Time) (p *TLSCertKeyPair, err error) {
|
||||
if !validLookingCertDomain(domain) {
|
||||
// Before we read files from disk using it, validate it's halfway
|
||||
// reasonable looking.
|
||||
return nil, fmt.Errorf("invalid domain %q", domain)
|
||||
}
|
||||
return b.getCertStore(dir).Read(domain, now)
|
||||
return cs.Read(domain, now)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) getCertPEM(ctx context.Context, logf logger.Logf, traceACME func(any), dir, domain string, now time.Time) (*TLSCertKeyPair, error) {
|
||||
func (b *LocalBackend) getCertPEM(ctx context.Context, cs certStore, logf logger.Logf, traceACME func(any), domain string, now time.Time) (*TLSCertKeyPair, error) {
|
||||
acmeMu.Lock()
|
||||
defer acmeMu.Unlock()
|
||||
|
||||
if p, err := b.getCertPEMCached(dir, domain, now); err == nil {
|
||||
if p, err := getCertPEMCached(cs, domain, now); err == nil {
|
||||
return p, nil
|
||||
} else if !errors.Is(err, ipn.ErrStateNotExist) && !errors.Is(err, errCertExpired) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := acmeKey(dir)
|
||||
key, err := acmeKey(cs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acmeKey: %w", err)
|
||||
}
|
||||
@@ -366,8 +413,7 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, logf logger.Logf, traceAC
|
||||
if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certStore := b.getCertStore(dir)
|
||||
if err := certStore.WriteKey(domain, privPEM.Bytes()); err != nil {
|
||||
if err := cs.WriteKey(domain, privPEM.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -390,7 +436,7 @@ func (b *LocalBackend) getCertPEM(ctx context.Context, logf logger.Logf, traceAC
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := certStore.WriteCert(domain, certPEM.Bytes()); err != nil {
|
||||
if err := cs.WriteCert(domain, certPEM.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -444,14 +490,15 @@ func parsePrivateKey(der []byte) (crypto.Signer, error) {
|
||||
return nil, errors.New("acme/autocert: failed to parse private key")
|
||||
}
|
||||
|
||||
func acmeKey(dir string) (crypto.Signer, error) {
|
||||
pemName := filepath.Join(dir, "acme-account.key.pem")
|
||||
if v, err := os.ReadFile(pemName); err == nil {
|
||||
func acmeKey(cs certStore) (crypto.Signer, error) {
|
||||
if v, err := cs.ACMEKey(); err == nil {
|
||||
priv, _ := pem.Decode(v)
|
||||
if priv == nil || !strings.Contains(priv.Type, "PRIVATE") {
|
||||
return nil, errors.New("acme/autocert: invalid account key found in cache")
|
||||
}
|
||||
return parsePrivateKey(priv.Bytes)
|
||||
} else if err != nil && !errors.Is(err, ipn.ErrStateNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -462,7 +509,7 @@ func acmeKey(dir string) (crypto.Signer, error) {
|
||||
if err := encodeECDSAKey(&pemBuf, privKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(pemName, pemBuf.Bytes(), 0600); err != nil {
|
||||
if err := cs.WriteACMEKey(pemBuf.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return privKey, nil
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -43,6 +44,8 @@ import (
|
||||
"tailscale.com/ipn/ipnauth"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/ipn/policy"
|
||||
"tailscale.com/log/sockstatlog"
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/net/dns"
|
||||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
@@ -149,6 +152,23 @@ type LocalBackend struct {
|
||||
sshAtomicBool atomic.Bool
|
||||
shutdownCalled bool // if Shutdown has been called
|
||||
debugSink *capture.Sink
|
||||
sockstatLogger *sockstatlog.Logger
|
||||
|
||||
// getTCPHandlerForFunnelFlow returns a handler for an incoming TCP flow for
|
||||
// the provided srcAddr and dstPort if one exists.
|
||||
//
|
||||
// srcAddr is the source address of the flow, not the address of the Funnel
|
||||
// node relaying the flow.
|
||||
// dstPort is the destination port of the flow.
|
||||
//
|
||||
// It returns nil if there is no known handler for this flow.
|
||||
//
|
||||
// This is specifically used to handle TCP flows for Funnel connections to tsnet
|
||||
// servers.
|
||||
//
|
||||
// It is set once during initialization, and can be nil if SetTCPHandlerForFunnelFlow
|
||||
// is never called.
|
||||
getTCPHandlerForFunnelFlow func(srcAddr netip.AddrPort, dstPort uint16) (handler func(net.Conn))
|
||||
|
||||
// lastProfileID tracks the last profile we've seen from the ProfileManager.
|
||||
// It's used to detect when the user has changed their profile.
|
||||
@@ -278,7 +298,7 @@ func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, diale
|
||||
statsLogf: logger.LogOnChange(logf, 5*time.Minute, time.Now),
|
||||
e: e,
|
||||
pm: pm,
|
||||
store: pm.Store(),
|
||||
store: store,
|
||||
dialer: dialer,
|
||||
backendLogID: logid,
|
||||
state: ipn.NoState,
|
||||
@@ -288,6 +308,14 @@ func NewLocalBackend(logf logger.Logf, logid string, store ipn.StateStore, diale
|
||||
loginFlags: loginFlags,
|
||||
}
|
||||
|
||||
// for now, only log sockstats on unstable builds
|
||||
if version.IsUnstableBuild() {
|
||||
b.sockstatLogger, err = sockstatlog.NewLogger(logpolicy.LogsDir(logf), logf)
|
||||
if err != nil {
|
||||
log.Printf("error setting up sockstat logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default filter blocks everything and logs nothing, until Start() is called.
|
||||
b.setFilter(filter.NewAllowNone(logf, &netipx.IPSet{}))
|
||||
|
||||
@@ -525,6 +553,10 @@ func (b *LocalBackend) Shutdown() {
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if b.sockstatLogger != nil {
|
||||
b.sockstatLogger.Shutdown()
|
||||
}
|
||||
|
||||
b.unregisterLinkMon()
|
||||
b.unregisterHealthWatch()
|
||||
if cc != nil {
|
||||
@@ -2498,6 +2530,9 @@ func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error {
|
||||
if err := b.checkExitNodePrefsLocked(p); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if err := b.checkFunnelEnabledLocked(p); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return multierr.New(errs...)
|
||||
}
|
||||
|
||||
@@ -2520,9 +2555,6 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error {
|
||||
if version.IsSandboxedMacOS() {
|
||||
return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.")
|
||||
}
|
||||
if !envknob.UseWIPCode() {
|
||||
return errors.New("The Tailscale SSH server is disabled on macOS tailscaled by default. To try, set env TAILSCALE_USE_WIP_CODE=1")
|
||||
}
|
||||
case "freebsd", "openbsd":
|
||||
default:
|
||||
return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS)
|
||||
@@ -2585,6 +2617,13 @@ func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) checkFunnelEnabledLocked(p *ipn.Prefs) error {
|
||||
if p.ShieldsUp && b.serveConfig.IsFunnelOn() {
|
||||
return errors.New("Cannot enable shields-up when Funnel is enabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (ipn.PrefsView, error) {
|
||||
b.mu.Lock()
|
||||
if mp.EggSet {
|
||||
@@ -3117,6 +3156,12 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, prefs ipn.PrefsView, logf logger.
|
||||
return dcfg
|
||||
}
|
||||
|
||||
// SetTCPHandlerForFunnelFlow sets the TCP handler for Funnel flows.
|
||||
// It should only be called before the LocalBackend is used.
|
||||
func (b *LocalBackend) SetTCPHandlerForFunnelFlow(h func(src netip.AddrPort, dstPort uint16) (handler func(net.Conn))) {
|
||||
b.getTCPHandlerForFunnelFlow = h
|
||||
}
|
||||
|
||||
// SetVarRoot sets the root directory of Tailscale's writable
|
||||
// storage area . (e.g. "/var/lib/tailscale")
|
||||
//
|
||||
|
||||
@@ -6,7 +6,9 @@ package ipnlocal
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -104,6 +106,7 @@ func (b *LocalBackend) tkaFilterNetmapLocked(nm *netmap.NetworkMap) {
|
||||
ID: p.ID,
|
||||
StableID: p.StableID,
|
||||
TailscaleIPs: make([]netip.Addr, len(p.Addresses)),
|
||||
NodeKey: p.Key,
|
||||
}
|
||||
for i, addr := range p.Addresses {
|
||||
if addr.IsSingleIP() && tsaddr.IsTailscaleIP(addr.Addr()) {
|
||||
@@ -847,6 +850,40 @@ func (b *LocalBackend) NetworkLockAffectedSigs(keyID tkatype.KeyID) ([]tkatype.M
|
||||
return resp.Signatures, nil
|
||||
}
|
||||
|
||||
var tkaSuffixEncoder = base64.RawStdEncoding
|
||||
|
||||
// NetworkLockWrapPreauthKey wraps a pre-auth key with information to
|
||||
// enable unattended bringup in the locked tailnet.
|
||||
//
|
||||
// The provided trusted tailnet-lock key is used to sign
|
||||
// a SigCredential structure, which is encoded along with the
|
||||
// private key and appended to the pre-auth key.
|
||||
func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.NLPrivate) (string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.tka == nil {
|
||||
return "", errNetworkLockNotActive
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(nil) // nil == crypto/rand
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sig := tka.NodeKeySignature{
|
||||
SigKind: tka.SigCredential,
|
||||
KeyID: tkaKey.KeyID(),
|
||||
WrappingPubkey: pub,
|
||||
}
|
||||
sig.Signature, err = tkaKey.SignNKS(sig.SigHash())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("signing failed: %w", err)
|
||||
}
|
||||
|
||||
b.logf("Generated network-lock credential signature using %s", tkaKey.Public().CLIString())
|
||||
return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil
|
||||
}
|
||||
|
||||
func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) {
|
||||
p, err := nodeInfo.NodePublic.MarshalBinary()
|
||||
if err != nil {
|
||||
|
||||
@@ -761,12 +761,12 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque
|
||||
bad("Tailscale-Ingress-Src header invalid; want ip:port")
|
||||
return
|
||||
}
|
||||
target := r.Header.Get("Tailscale-Ingress-Target")
|
||||
target := ipn.HostPort(r.Header.Get("Tailscale-Ingress-Target"))
|
||||
if target == "" {
|
||||
bad("Tailscale-Ingress-Target header not set")
|
||||
return
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(target); err != nil {
|
||||
if _, _, err := net.SplitHostPort(string(target)); err != nil {
|
||||
bad("Tailscale-Ingress-Target header invalid; want host:port")
|
||||
return
|
||||
}
|
||||
@@ -779,13 +779,17 @@ func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Reque
|
||||
return nil, false
|
||||
}
|
||||
io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n")
|
||||
return conn, true
|
||||
return &ipn.FunnelConn{
|
||||
Conn: conn,
|
||||
Src: srcAddr,
|
||||
Target: target,
|
||||
}, true
|
||||
}
|
||||
sendRST := func() {
|
||||
http.Error(w, "denied", http.StatusForbidden)
|
||||
}
|
||||
|
||||
h.ps.b.HandleIngressTCPConn(h.peerNode, ipn.HostPort(target), srcAddr, getConn, sendRST)
|
||||
h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConn, sendRST)
|
||||
}
|
||||
|
||||
func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -861,7 +865,7 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintln(w, "<!DOCTYPE html><h1>Socket Stats</h1>")
|
||||
|
||||
stats := sockstats.Get()
|
||||
stats, validation := sockstats.GetWithValidation()
|
||||
if stats == nil {
|
||||
fmt.Fprintln(w, "No socket stats available")
|
||||
return
|
||||
@@ -876,6 +880,7 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
fmt.Fprintf(w, "<th>Tx (%s)</th>", html.EscapeString(iface))
|
||||
fmt.Fprintf(w, "<th>Rx (%s)</th>", html.EscapeString(iface))
|
||||
}
|
||||
fmt.Fprintln(w, "<th>Validation</th>")
|
||||
fmt.Fprintln(w, "</thead>")
|
||||
|
||||
fmt.Fprintln(w, "<tbody>")
|
||||
@@ -887,10 +892,10 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
return a.String() < b.String()
|
||||
})
|
||||
|
||||
txTotal := int64(0)
|
||||
rxTotal := int64(0)
|
||||
txTotalByInterface := map[string]int64{}
|
||||
rxTotalByInterface := map[string]int64{}
|
||||
txTotal := uint64(0)
|
||||
rxTotal := uint64(0)
|
||||
txTotalByInterface := map[string]uint64{}
|
||||
rxTotalByInterface := map[string]uint64{}
|
||||
|
||||
for _, label := range labels {
|
||||
stat := stats.Stats[label]
|
||||
@@ -908,6 +913,17 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
txTotalByInterface[iface] += stat.TxBytesByInterface[iface]
|
||||
rxTotalByInterface[iface] += stat.RxBytesByInterface[iface]
|
||||
}
|
||||
|
||||
if validationStat, ok := validation.Stats[label]; ok && (validationStat.RxBytes > 0 || validationStat.TxBytes > 0) {
|
||||
fmt.Fprintf(w, "<td>Tx=%d (%+d) Rx=%d (%+d)</td>",
|
||||
validationStat.TxBytes,
|
||||
int64(validationStat.TxBytes)-int64(stat.TxBytes),
|
||||
validationStat.RxBytes,
|
||||
int64(validationStat.RxBytes)-int64(stat.RxBytes))
|
||||
} else {
|
||||
fmt.Fprintln(w, "<td></td>")
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "</tr>")
|
||||
}
|
||||
fmt.Fprintln(w, "</tbody>")
|
||||
@@ -920,6 +936,7 @@ func (h *peerAPIHandler) handleServeSockStats(w http.ResponseWriter, r *http.Req
|
||||
fmt.Fprintf(w, "<th>%d</th>", txTotalByInterface[iface])
|
||||
fmt.Fprintf(w, "<th>%d</th>", rxTotalByInterface[iface])
|
||||
}
|
||||
fmt.Fprintln(w, "<th></th>")
|
||||
fmt.Fprintln(w, "</tfoot>")
|
||||
|
||||
fmt.Fprintln(w, "</table>")
|
||||
|
||||
@@ -218,6 +218,11 @@ func (b *LocalBackend) SetServeConfig(config *ipn.ServeConfig) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
prefs := b.pm.CurrentPrefs()
|
||||
if config.IsFunnelOn() && prefs.ShieldsUp() {
|
||||
return errors.New("Unable to turn on Funnel while shields-up is enabled")
|
||||
}
|
||||
|
||||
nm := b.netMap
|
||||
if nm == nil {
|
||||
return errors.New("netMap is nil")
|
||||
@@ -281,9 +286,22 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ip
|
||||
sendRST()
|
||||
return
|
||||
}
|
||||
dport := uint16(port16)
|
||||
if b.getTCPHandlerForFunnelFlow != nil {
|
||||
handler := b.getTCPHandlerForFunnelFlow(srcAddr, dport)
|
||||
if handler != nil {
|
||||
c, ok := getConn()
|
||||
if !ok {
|
||||
b.logf("localbackend: getConn didn't complete from %v to port %v", srcAddr, dport)
|
||||
return
|
||||
}
|
||||
handler(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
// TODO(bradfitz): pass ingressPeer etc in context to HandleInterceptedTCPConn,
|
||||
// extend serveHTTPContext or similar.
|
||||
b.HandleInterceptedTCPConn(uint16(port16), srcAddr, getConn, sendRST)
|
||||
b.HandleInterceptedTCPConn(dport, srcAddr, getConn, sendRST)
|
||||
}
|
||||
|
||||
func (b *LocalBackend) HandleInterceptedTCPConn(dport uint16, srcAddr netip.AddrPort, getConn func() (net.Conn, bool), sendRST func()) {
|
||||
@@ -426,18 +444,26 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.Reverse
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid url %s: %w", targetURL, err)
|
||||
}
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = &http.Transport{
|
||||
DialContext: b.dialer.SystemDial,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
rp := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(u)
|
||||
r.Out.Host = r.In.Host
|
||||
if c, ok := r.Out.Context().Value(serveHTTPContextKey{}).(*serveHTTPContext); ok {
|
||||
r.Out.Header.Set("X-Forwarded-For", c.SrcAddr.Addr().String())
|
||||
}
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: b.dialer.SystemDial,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure,
|
||||
},
|
||||
// Values for the following parameters have been copied from http.DefaultTransport.
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
// Values for the following parameters have been copied from http.DefaultTransport.
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
return rp, nil
|
||||
}
|
||||
@@ -463,7 +489,12 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unknown proxy destination", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p.(http.Handler).ServeHTTP(w, r)
|
||||
h := p.(http.Handler)
|
||||
// Trim the mount point from the URL path before proxying. (#6571)
|
||||
if r.URL.Path != "/" {
|
||||
h = http.StripPrefix(strings.TrimSuffix(mountPoint, "/"), h)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ type TKAFilteredPeer struct {
|
||||
ID tailcfg.NodeID
|
||||
StableID tailcfg.StableNodeID
|
||||
TailscaleIPs []netip.Addr // Tailscale IP(s) assigned to this node
|
||||
NodeKey key.NodePublic
|
||||
}
|
||||
|
||||
// NetworkLockStatus represents whether network-lock is enabled,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/netip"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
// Clone makes a deep copy of TKAFilteredPeer.
|
||||
@@ -29,4 +30,5 @@ var _TKAFilteredPeerCloneNeedsRegeneration = TKAFilteredPeer(struct {
|
||||
ID tailcfg.NodeID
|
||||
StableID tailcfg.StableNodeID
|
||||
TailscaleIPs []netip.Addr
|
||||
NodeKey key.NodePublic
|
||||
}{})
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"tailscale.com/derp/derphttp"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,6 +55,9 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
st.Info = append(st.Info, fmt.Sprintf("Region %v == %q", reg.RegionID, reg.RegionCode))
|
||||
if len(dm.Regions) == 1 {
|
||||
st.Warnings = append(st.Warnings, "Having only a single DERP region (i.e. removing the default Tailscale-provided regions) is a single point of failure and could hamper connectivity")
|
||||
}
|
||||
|
||||
if reg.Avoid {
|
||||
st.Warnings = append(st.Warnings, "Region is marked with Avoid bit")
|
||||
@@ -60,10 +67,120 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
var (
|
||||
dialer net.Dialer
|
||||
client *http.Client = http.DefaultClient
|
||||
)
|
||||
checkConn := func(derpNode *tailcfg.DERPNode) bool {
|
||||
port := firstNonzero(derpNode.DERPPort, 443)
|
||||
|
||||
var (
|
||||
hasIPv4 bool
|
||||
hasIPv6 bool
|
||||
)
|
||||
|
||||
// Check IPv4 first
|
||||
addr := net.JoinHostPort(firstNonzero(derpNode.IPv4, derpNode.HostName), strconv.Itoa(port))
|
||||
conn, err := dialer.DialContext(ctx, "tcp4", addr)
|
||||
if err != nil {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Error connecting to node %q @ %q over IPv4: %v", derpNode.HostName, addr, err))
|
||||
} else {
|
||||
defer conn.Close()
|
||||
|
||||
// Upgrade to TLS and verify that works properly.
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: firstNonzero(derpNode.CertName, derpNode.HostName),
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Error upgrading connection to node %q @ %q to TLS over IPv4: %v", derpNode.HostName, addr, err))
|
||||
} else {
|
||||
hasIPv4 = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check IPv6
|
||||
addr = net.JoinHostPort(firstNonzero(derpNode.IPv6, derpNode.HostName), strconv.Itoa(port))
|
||||
conn, err = dialer.DialContext(ctx, "tcp6", addr)
|
||||
if err != nil {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Error connecting to node %q @ %q over IPv6: %v", derpNode.HostName, addr, err))
|
||||
} else {
|
||||
defer conn.Close()
|
||||
|
||||
// Upgrade to TLS and verify that works properly.
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: firstNonzero(derpNode.CertName, derpNode.HostName),
|
||||
// TODO(andrew-d): we should print more
|
||||
// detailed failure information on if/why TLS
|
||||
// verification fails
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Error upgrading connection to node %q @ %q to TLS over IPv6: %v", derpNode.HostName, addr, err))
|
||||
} else {
|
||||
hasIPv6 = true
|
||||
}
|
||||
}
|
||||
|
||||
// If we only have an IPv6 conn, then warn; we want both.
|
||||
if hasIPv6 && !hasIPv4 {
|
||||
st.Warnings = append(st.Warnings, fmt.Sprintf("Node %q only has IPv6 connectivity, not IPv4", derpNode.HostName))
|
||||
} else if hasIPv6 && hasIPv4 {
|
||||
st.Info = append(st.Info, fmt.Sprintf("Node %q has working IPv4 and IPv6 connectivity", derpNode.HostName))
|
||||
}
|
||||
|
||||
return hasIPv4 || hasIPv6
|
||||
}
|
||||
|
||||
// Start by checking whether we can establish a HTTP connection
|
||||
for _, derpNode := range reg.Nodes {
|
||||
connSuccess := checkConn(derpNode)
|
||||
|
||||
// Verify that the /generate_204 endpoint works
|
||||
captivePortalURL := "http://" + derpNode.HostName + "/generate_204"
|
||||
resp, err := client.Get(captivePortalURL)
|
||||
if err != nil {
|
||||
st.Warnings = append(st.Warnings, fmt.Sprintf("Error making request to the captive portal check %q; is port 80 blocked?", captivePortalURL))
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if !connSuccess {
|
||||
continue
|
||||
}
|
||||
|
||||
fakePrivKey := key.NewNode()
|
||||
|
||||
// Next, repeatedly get the server key to see if the node is
|
||||
// behind a load balancer (incorrectly).
|
||||
serverPubKeys := make(map[key.NodePublic]bool)
|
||||
for i := 0; i < 5; i++ {
|
||||
func() {
|
||||
rc := derphttp.NewRegionClient(fakePrivKey, h.logf, func() *tailcfg.DERPRegion {
|
||||
return &tailcfg.DERPRegion{
|
||||
RegionID: reg.RegionID,
|
||||
RegionCode: reg.RegionCode,
|
||||
RegionName: reg.RegionName,
|
||||
Nodes: []*tailcfg.DERPNode{derpNode},
|
||||
}
|
||||
})
|
||||
if err := rc.Connect(ctx); err != nil {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Error connecting to node %q @ try %d: %v", derpNode.HostName, i, err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(serverPubKeys) == 0 {
|
||||
st.Info = append(st.Info, fmt.Sprintf("Successfully established a DERP connection with node %q", derpNode.HostName))
|
||||
}
|
||||
serverPubKeys[rc.ServerPublicKey()] = true
|
||||
}()
|
||||
}
|
||||
if len(serverPubKeys) > 1 {
|
||||
st.Errors = append(st.Errors, fmt.Sprintf("Received multiple server public keys (%d); is the DERP server behind a load balancer?", len(serverPubKeys)))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(bradfitz): finish:
|
||||
// * first try TCP connection
|
||||
// * reconnect 4 or 5 times; see if we ever get a different server key.
|
||||
// if so, they're load balancing the wrong way. error.
|
||||
// * try to DERP auth with new public key.
|
||||
// * if rejected, add Info that it's likely the DERP server authz is on,
|
||||
// try with LocalBackend's node key instead.
|
||||
@@ -75,17 +192,17 @@ func (h *Handler) serveDebugDERPRegion(w http.ResponseWriter, r *http.Request) {
|
||||
// in DERPRegion. Or maybe even list all their server pub keys that it's peered
|
||||
// with.
|
||||
// * try STUN queries
|
||||
// * warn about IPv6 only
|
||||
// * If their certificate is bad, either expired or just wrongly
|
||||
// issued in the first place, tell them specifically that the
|
||||
// cert is bad not just that the connection failed.
|
||||
// * If /generate_204 on port 80 cannot be reached, warn
|
||||
// that they won't get captive portal detection and
|
||||
// should allow port 80.
|
||||
// * If they have exactly one DERP region because they
|
||||
// removed all of Tailscale's DERPs, warn that they have
|
||||
// a SPOF that will hamper even direct connections from
|
||||
// working. (warning, not error, as that's probably a likely
|
||||
// config for headscale users)
|
||||
st.Info = append(st.Info, "TODO: 🦉")
|
||||
}
|
||||
|
||||
func firstNonzero[T comparable](items ...T) T {
|
||||
var zero T
|
||||
for _, item := range items {
|
||||
if item != zero {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return zero
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ var handler = map[string]localAPIHandler{
|
||||
"tka/disable": (*Handler).serveTKADisable,
|
||||
"tka/force-local-disable": (*Handler).serveTKALocalDisable,
|
||||
"tka/affected-sigs": (*Handler).serveTKAAffectedSigs,
|
||||
"tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey,
|
||||
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
|
||||
"watch-ipn-bus": (*Handler).serveWatchIPNBus,
|
||||
"whois": (*Handler).serveWhoIs,
|
||||
@@ -1570,6 +1571,40 @@ func (h *Handler) serveTKAModify(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != httpm.POST {
|
||||
http.Error(w, "use POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
type wrapRequest struct {
|
||||
TSKey string
|
||||
TKAKey string // key.NLPrivate.MarshalText
|
||||
}
|
||||
var req wrapRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 12*1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var priv key.NLPrivate
|
||||
if err := priv.UnmarshalText([]byte(req.TKAKey)); err != nil {
|
||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
wrappedKey, err := h.b.NetworkLockWrapPreauthKey(req.TSKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(wrappedKey))
|
||||
}
|
||||
|
||||
func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "network-lock modify access denied", http.StatusForbidden)
|
||||
|
||||
119
ipn/serve.go
119
ipn/serve.go
@@ -3,6 +3,19 @@
|
||||
|
||||
package ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// ServeConfigKey returns a StateKey that stores the
|
||||
// JSON-encoded ServeConfig for a config profile.
|
||||
func ServeConfigKey(profileID ProfileID) StateKey {
|
||||
@@ -29,6 +42,26 @@ type ServeConfig struct {
|
||||
// There is no implicit port 443. It must contain a colon.
|
||||
type HostPort string
|
||||
|
||||
// A FunnelConn wraps a net.Conn that is coming over a
|
||||
// Funnel connection. It can be used to determine further
|
||||
// information about the connection, like the source address
|
||||
// and the target SNI name.
|
||||
type FunnelConn struct {
|
||||
// Conn is the underlying connection.
|
||||
net.Conn
|
||||
|
||||
// Target is what was presented in the "Tailscale-Ingress-Target"
|
||||
// HTTP header.
|
||||
Target HostPort
|
||||
|
||||
// Src is the source address of the connection.
|
||||
// This is the address of the client that initiated the
|
||||
// connection, not the address of the Tailscale Funnel
|
||||
// node which is relaying the connection. That address
|
||||
// can be found in Conn.RemoteAddr.
|
||||
Src netip.AddrPort
|
||||
}
|
||||
|
||||
// WebServerConfig describes a web server's configuration.
|
||||
type WebServerConfig struct {
|
||||
Handlers map[string]*HTTPHandler // mountPoint => handler
|
||||
@@ -130,6 +163,12 @@ func (sc *ServeConfig) IsServingWeb(port uint16) bool {
|
||||
return sc.TCP[port].HTTPS
|
||||
}
|
||||
|
||||
// IsFunnelOn checks if ServeConfig is currently allowing
|
||||
// funnel traffic for any host:port.
|
||||
//
|
||||
// View version of ServeConfig.IsFunnelOn.
|
||||
func (v ServeConfigView) IsFunnelOn() bool { return v.ж.IsFunnelOn() }
|
||||
|
||||
// IsFunnelOn checks if ServeConfig is currently allowing
|
||||
// funnel traffic for any host:port.
|
||||
func (sc *ServeConfig) IsFunnelOn() bool {
|
||||
@@ -143,3 +182,83 @@ func (sc *ServeConfig) IsFunnelOn() bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckFunnelAccess checks whether Funnel access is allowed for the given node
|
||||
// and port.
|
||||
// It checks:
|
||||
// 1. Funnel is enabled on the Tailnet
|
||||
// 2. HTTPS is enabled on the Tailnet
|
||||
// 3. the node has the "funnel" nodeAttr
|
||||
// 4. the port is allowed for Funnel
|
||||
//
|
||||
// The nodeAttrs arg should be the node's Self.Capabilities which should contain
|
||||
// the attribute we're checking for and possibly warning-capabilities for
|
||||
// Funnel.
|
||||
func CheckFunnelAccess(port uint16, nodeAttrs []string) error {
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoInvite) {
|
||||
return errors.New("Funnel not enabled; See https://tailscale.com/s/no-funnel.")
|
||||
}
|
||||
if slices.Contains(nodeAttrs, tailcfg.CapabilityWarnFunnelNoHTTPS) {
|
||||
return errors.New("Funnel not available; HTTPS must be enabled. See https://tailscale.com/s/https.")
|
||||
}
|
||||
if !slices.Contains(nodeAttrs, tailcfg.NodeAttrFunnel) {
|
||||
return errors.New("Funnel not available; \"funnel\" node attribute not set. See https://tailscale.com/s/no-funnel.")
|
||||
}
|
||||
return checkFunnelPort(port, nodeAttrs)
|
||||
}
|
||||
|
||||
// checkFunnelPort checks whether the given port is allowed for Funnel.
|
||||
// It uses the tailcfg.CapabilityFunnelPorts nodeAttr to determine the allowed
|
||||
// ports.
|
||||
func checkFunnelPort(wantedPort uint16, nodeAttrs []string) error {
|
||||
deny := func(allowedPorts string) error {
|
||||
if allowedPorts == "" {
|
||||
return fmt.Errorf("port %d is not allowed for funnel", wantedPort)
|
||||
}
|
||||
return fmt.Errorf("port %d is not allowed for funnel; allowed ports are: %v", wantedPort, allowedPorts)
|
||||
}
|
||||
var portsStr string
|
||||
for _, attr := range nodeAttrs {
|
||||
if !strings.HasPrefix(attr, tailcfg.CapabilityFunnelPorts) {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(attr)
|
||||
if err != nil {
|
||||
return deny("")
|
||||
}
|
||||
portsStr = u.Query().Get("ports")
|
||||
if portsStr == "" {
|
||||
return deny("")
|
||||
}
|
||||
u.RawQuery = ""
|
||||
if u.String() != tailcfg.CapabilityFunnelPorts {
|
||||
return deny("")
|
||||
}
|
||||
}
|
||||
wantedPortString := strconv.Itoa(int(wantedPort))
|
||||
for _, ps := range strings.Split(portsStr, ",") {
|
||||
if ps == "" {
|
||||
continue
|
||||
}
|
||||
first, last, ok := strings.Cut(ps, "-")
|
||||
if !ok {
|
||||
if first == wantedPortString {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
fp, err := strconv.ParseUint(first, 10, 16)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lp, err := strconv.ParseUint(last, 10, 16)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pr := tailcfg.PortRange{First: uint16(fp), Last: uint16(lp)}
|
||||
if pr.Contains(wantedPort) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return deny(portsStr)
|
||||
}
|
||||
|
||||
40
ipn/serve_test.go
Normal file
40
ipn/serve_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
package ipn
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
func TestCheckFunnelAccess(t *testing.T) {
|
||||
portAttr := "https://tailscale.com/cap/funnel-ports?ports=443,8080-8090,8443,"
|
||||
tests := []struct {
|
||||
port uint16
|
||||
caps []string
|
||||
wantErr bool
|
||||
}{
|
||||
{443, []string{portAttr}, true}, // No "funnel" attribute
|
||||
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoInvite}, true},
|
||||
{443, []string{portAttr, tailcfg.CapabilityWarnFunnelNoHTTPS}, true},
|
||||
{443, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8443, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8321, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{8083, []string{portAttr, tailcfg.NodeAttrFunnel}, false},
|
||||
{8091, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
{3000, []string{portAttr, tailcfg.NodeAttrFunnel}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := CheckFunnelAccess(tt.port, tt.caps)
|
||||
switch {
|
||||
case err != nil && tt.wantErr,
|
||||
err == nil && !tt.wantErr:
|
||||
continue
|
||||
case tt.wantErr:
|
||||
t.Fatalf("got no error, want error")
|
||||
case !tt.wantErr:
|
||||
t.Fatalf("got error %v, want no error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,14 +52,14 @@ Client][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [go4.org/mem](https://pkg.go.dev/go4.org/mem) ([Apache-2.0](https://github.com/go4org/mem/blob/927187094b94/LICENSE))
|
||||
- [go4.org/netipx](https://pkg.go.dev/go4.org/netipx) ([BSD-3-Clause](https://github.com/go4org/netipx/blob/7e7bdc8411bf/LICENSE))
|
||||
- [go4.org/unsafe/assume-no-moving-gc](https://pkg.go.dev/go4.org/unsafe/assume-no-moving-gc) ([BSD-3-Clause](https://github.com/go4org/unsafe-assume-no-moving-gc/blob/ee73d164e760/LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.3.0:LICENSE))
|
||||
- [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) ([BSD-3-Clause](https://cs.opensource.google/go/x/crypto/+/v0.6.0:LICENSE))
|
||||
- [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/47842c84:LICENSE))
|
||||
- [golang.org/x/exp/shiny](https://pkg.go.dev/golang.org/x/exp/shiny) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/334a2380:shiny/LICENSE))
|
||||
- [golang.org/x/image](https://pkg.go.dev/golang.org/x/image) ([BSD-3-Clause](https://cs.opensource.google/go/x/image/+/v0.5.0:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.5.0:LICENSE))
|
||||
- [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.7.0:LICENSE))
|
||||
- [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.1.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/e7d7f631:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.4.0:LICENSE))
|
||||
- [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.5.0:LICENSE))
|
||||
- [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) ([BSD-3-Clause](https://cs.opensource.google/go/x/term/+/v0.5.0:LICENSE))
|
||||
- [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.7.0:LICENSE))
|
||||
- [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/579cf78f:LICENSE))
|
||||
- [gvisor.dev/gvisor/pkg](https://pkg.go.dev/gvisor.dev/gvisor/pkg) ([Apache-2.0](https://github.com/google/gvisor/blob/703fd9b7fbc0/LICENSE))
|
||||
|
||||
@@ -15,7 +15,7 @@ and [iOS][]. See also the dependencies in the [Tailscale CLI][].
|
||||
- [github.com/fxamacker/cbor/v2](https://pkg.go.dev/github.com/fxamacker/cbor/v2) ([MIT](https://github.com/fxamacker/cbor/blob/v2.4.0/LICENSE))
|
||||
- [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.0.6/LICENSE))
|
||||
- [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.0.1/LICENSE))
|
||||
- [github.com/google/btree](https://pkg.go.dev/github.com/google/btree) ([Apache-2.0](https://github.com/google/btree/blob/v1.1.2/LICENSE))
|
||||
- [github.com/hdevalence/ed25519consensus](https://pkg.go.dev/github.com/hdevalence/ed25519consensus) ([BSD-3-Clause](https://github.com/hdevalence/ed25519consensus/blob/c00d1f31bab3/LICENSE))
|
||||
- [github.com/illarion/gonotify](https://pkg.go.dev/github.com/illarion/gonotify) ([MIT](https://github.com/illarion/gonotify/blob/v1.0.1/LICENSE))
|
||||
- [github.com/insomniacslk/dhcp](https://pkg.go.dev/github.com/insomniacslk/dhcp) ([BSD-3-Clause](https://github.com/insomniacslk/dhcp/blob/de60144f33f8/LICENSE))
|
||||
|
||||
164
log/sockstatlog/logger.go
Normal file
164
log/sockstatlog/logger.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package sockstatlog provides a logger for capturing and storing network socket stats.
|
||||
package sockstatlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"tailscale.com/logtail/filch"
|
||||
"tailscale.com/net/sockstats"
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/mak"
|
||||
)
|
||||
|
||||
// pollPeriod specifies how often to poll for socket stats.
|
||||
const pollPeriod = time.Second / 10
|
||||
|
||||
// Logger logs statistics about network sockets.
|
||||
type Logger struct {
|
||||
ctx context.Context
|
||||
cancelFn context.CancelFunc
|
||||
|
||||
ticker *time.Ticker
|
||||
logf logger.Logf
|
||||
logbuffer *filch.Filch
|
||||
}
|
||||
|
||||
// deltaStat represents the bytes transferred during a time period.
|
||||
// The first element is transmitted bytes, the second element is received bytes.
|
||||
type deltaStat [2]uint64
|
||||
|
||||
// event represents the socket stats on a specific interface during a time period.
|
||||
type event struct {
|
||||
// Time is when the event started as a Unix timestamp in milliseconds.
|
||||
Time int64 `json:"t"`
|
||||
|
||||
// Duration is the duration of this event in milliseconds.
|
||||
Duration int64 `json:"d"`
|
||||
|
||||
// IsCellularInterface is set to 1 if the traffic was sent over a cellular interface.
|
||||
IsCellularInterface int `json:"c,omitempty"`
|
||||
|
||||
// Stats records the stats for each Label during the time period.
|
||||
Stats map[sockstats.Label]deltaStat `json:"s"`
|
||||
}
|
||||
|
||||
// NewLogger returns a new Logger that will store stats in logdir.
|
||||
// On platforms that do not support sockstat logging, a nil Logger will be returned.
|
||||
// The returned Logger must be shut down with Shutdown when it is no longer needed.
|
||||
func NewLogger(logdir string, logf logger.Logf) (*Logger, error) {
|
||||
if !sockstats.IsAvailable {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(logdir, 0755); err != nil && !os.IsExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
filchPrefix := filepath.Join(logdir, "sockstats")
|
||||
filch, err := filch.New(filchPrefix, filch.Options{ReplaceStderr: false})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
logger := &Logger{
|
||||
ctx: ctx,
|
||||
cancelFn: cancel,
|
||||
ticker: time.NewTicker(pollPeriod),
|
||||
logf: logf,
|
||||
logbuffer: filch,
|
||||
}
|
||||
|
||||
go logger.poll()
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// poll fetches the current socket stats at the configured time interval,
|
||||
// calculates the delta since the last poll, and logs any non-zero values.
|
||||
// This method does not return.
|
||||
func (l *Logger) poll() {
|
||||
// last is the last set of socket stats we saw.
|
||||
var lastStats *sockstats.SockStats
|
||||
var lastTime time.Time
|
||||
|
||||
enc := json.NewEncoder(l.logbuffer)
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
case t := <-l.ticker.C:
|
||||
stats := sockstats.Get()
|
||||
if lastStats != nil {
|
||||
diffstats := delta(lastStats, stats)
|
||||
if len(diffstats) > 0 {
|
||||
e := event{
|
||||
Time: lastTime.UnixMilli(),
|
||||
Duration: t.Sub(lastTime).Milliseconds(),
|
||||
Stats: diffstats,
|
||||
}
|
||||
if stats.CurrentInterfaceCellular {
|
||||
e.IsCellularInterface = 1
|
||||
}
|
||||
if err := enc.Encode(e); err != nil {
|
||||
l.logf("sockstatlog: error encoding log: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTime = t
|
||||
lastStats = stats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Shutdown() {
|
||||
l.ticker.Stop()
|
||||
l.logbuffer.Close()
|
||||
l.cancelFn()
|
||||
}
|
||||
|
||||
// WriteLogs reads local logs, combining logs into events, and writes them to w.
|
||||
// Logs within eventWindow are combined into the same event.
|
||||
func (l *Logger) WriteLogs(w io.Writer) {
|
||||
if l == nil || l.logbuffer == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
b, err := l.logbuffer.TryReadLine()
|
||||
if err != nil {
|
||||
l.logf("sockstatlog: error reading log: %v", err)
|
||||
return
|
||||
}
|
||||
if b == nil {
|
||||
// no more log messages
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
// delta calculates the delta stats between two SockStats snapshots.
|
||||
// b is assumed to have occurred after a.
|
||||
// Zero values are omitted from the returned map, and an empty map is returned if no bytes were transferred.
|
||||
func delta(a, b *sockstats.SockStats) (stats map[sockstats.Label]deltaStat) {
|
||||
if a == nil || b == nil {
|
||||
return nil
|
||||
}
|
||||
for label, bs := range b.Stats {
|
||||
as := a.Stats[label]
|
||||
if as.TxBytes == bs.TxBytes && as.RxBytes == bs.RxBytes {
|
||||
// fast path for unchanged stats
|
||||
continue
|
||||
}
|
||||
mak.Set(&stats, label, deltaStat{bs.TxBytes - as.TxBytes, bs.RxBytes - as.RxBytes})
|
||||
}
|
||||
return stats
|
||||
}
|
||||
119
log/sockstatlog/logger_test.go
Normal file
119
log/sockstatlog/logger_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package sockstatlog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"tailscale.com/net/sockstats"
|
||||
)
|
||||
|
||||
func TestDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b *sockstats.SockStats
|
||||
wantStats map[sockstats.Label]deltaStat
|
||||
}{
|
||||
{
|
||||
name: "nil a stat",
|
||||
a: nil,
|
||||
b: &sockstats.SockStats{},
|
||||
wantStats: nil,
|
||||
},
|
||||
{
|
||||
name: "nil b stat",
|
||||
a: &sockstats.SockStats{},
|
||||
b: nil,
|
||||
wantStats: nil,
|
||||
},
|
||||
{
|
||||
name: "no change",
|
||||
a: &sockstats.SockStats{
|
||||
Stats: map[sockstats.Label]sockstats.SockStat{
|
||||
sockstats.LabelDERPHTTPClient: {
|
||||
TxBytes: 10,
|
||||
TxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
b: &sockstats.SockStats{
|
||||
Stats: map[sockstats.Label]sockstats.SockStat{
|
||||
sockstats.LabelDERPHTTPClient: {
|
||||
TxBytes: 10,
|
||||
TxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStats: nil,
|
||||
},
|
||||
{
|
||||
name: "tx after empty stat",
|
||||
a: &sockstats.SockStats{},
|
||||
b: &sockstats.SockStats{
|
||||
Stats: map[sockstats.Label]sockstats.SockStat{
|
||||
sockstats.LabelDERPHTTPClient: {
|
||||
TxBytes: 10,
|
||||
TxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Interfaces: []string{"en0"},
|
||||
},
|
||||
wantStats: map[sockstats.Label]deltaStat{
|
||||
sockstats.LabelDERPHTTPClient: {10, 0},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rx after non-empty stat",
|
||||
a: &sockstats.SockStats{
|
||||
Stats: map[sockstats.Label]sockstats.SockStat{
|
||||
sockstats.LabelDERPHTTPClient: {
|
||||
TxBytes: 10,
|
||||
RxBytes: 10,
|
||||
TxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
RxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
Interfaces: []string{"en0"},
|
||||
},
|
||||
b: &sockstats.SockStats{
|
||||
Stats: map[sockstats.Label]sockstats.SockStat{
|
||||
sockstats.LabelDERPHTTPClient: {
|
||||
TxBytes: 10,
|
||||
RxBytes: 30,
|
||||
TxBytesByInterface: map[string]uint64{
|
||||
"en0": 10,
|
||||
},
|
||||
RxBytesByInterface: map[string]uint64{
|
||||
"en0": 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
Interfaces: []string{"en0"},
|
||||
},
|
||||
wantStats: map[sockstats.Label]deltaStat{
|
||||
sockstats.LabelDERPHTTPClient: {0, 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotStats := delta(tt.a, tt.b)
|
||||
if !cmp.Equal(gotStats, tt.wantStats) {
|
||||
t.Errorf("gotStats = %v, want %v", gotStats, tt.wantStats)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -192,9 +192,9 @@ func (l logWriter) Write(buf []byte) (int, error) {
|
||||
return len(buf), nil
|
||||
}
|
||||
|
||||
// logsDir returns the directory to use for log configuration and
|
||||
// LogsDir returns the directory to use for log configuration and
|
||||
// buffer storage.
|
||||
func logsDir(logf logger.Logf) string {
|
||||
func LogsDir(logf logger.Logf) string {
|
||||
if d := os.Getenv("TS_LOGS_DIR"); d != "" {
|
||||
fi, err := os.Stat(d)
|
||||
if err == nil && fi.IsDir() {
|
||||
@@ -478,7 +478,7 @@ func NewWithConfigPath(collection, dir, cmdName string) *Policy {
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
dir = logsDir(earlyLogf)
|
||||
dir = LogsDir(earlyLogf)
|
||||
}
|
||||
if cmdName == "" {
|
||||
cmdName = version.CmdName()
|
||||
|
||||
@@ -463,14 +463,6 @@ func (l *Logger) upload(ctx context.Context, body []byte, origlen int) (uploaded
|
||||
return uploaded, fmt.Errorf("log upload of %d bytes %s failed %d: %q", len(body), compressedNote, resp.StatusCode, b)
|
||||
}
|
||||
|
||||
// Try to read to EOF, in case server's response is
|
||||
// chunked. We want to reuse the TCP connection if it's
|
||||
// HTTP/1. On success, we expect 0 bytes.
|
||||
// TODO(bradfitz): can remove a few days after 2020-04-04 once
|
||||
// server is fixed.
|
||||
if resp.ContentLength == -1 {
|
||||
resp.Body.Read(make([]byte, 1))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ func (f *forwarder) getKnownDoHClientForProvider(urlBase string) (c *http.Client
|
||||
dialer := dnscache.Dialer(nsDialer.DialContext, &dnscache.Resolver{
|
||||
SingleHost: dohURL.Hostname(),
|
||||
SingleHostStaticResult: allIPs,
|
||||
Logf: f.logf,
|
||||
})
|
||||
c = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
|
||||
@@ -250,10 +250,13 @@ func SetCachePath(path string) {
|
||||
// logfunc stores the logging function to use for this package.
|
||||
var logfunc syncs.AtomicValue[logger.Logf]
|
||||
|
||||
// SetLogger sets the logging function that this package will use. The default
|
||||
// logger if this function is not called is 'log.Printf'.
|
||||
func SetLogger(log logger.Logf) {
|
||||
logfunc.Store(log)
|
||||
// SetLogger sets the logging function that this package will use, and returns
|
||||
// the old value (which may be nil).
|
||||
//
|
||||
// If this function is never called, or if this function is called with a nil
|
||||
// value, 'log.Printf' will be used to print logs.
|
||||
func SetLogger(log logger.Logf) (old logger.Logf) {
|
||||
return logfunc.Swap(log)
|
||||
}
|
||||
|
||||
func logf(format string, args ...any) {
|
||||
|
||||
@@ -153,11 +153,9 @@ func LocalAddresses() (regular, loopback []netip.Addr, err error) {
|
||||
if len(regular4) == 0 && len(regular6) == 0 {
|
||||
// if we have no usable IP addresses then be willing to accept
|
||||
// addresses we otherwise wouldn't, like:
|
||||
// + 169.254.x.x (AWS Lambda uses NAT with these)
|
||||
// + 169.254.x.x (AWS Lambda and Azure App Services use NAT with these)
|
||||
// + IPv6 ULA (Google Cloud Run uses these with address translation)
|
||||
if hostinfo.GetEnvType() == hostinfo.AWSLambda {
|
||||
regular4 = linklocal4
|
||||
}
|
||||
regular4 = linklocal4
|
||||
regular6 = ula6
|
||||
}
|
||||
regular = append(regular4, regular6...)
|
||||
@@ -645,7 +643,14 @@ func isUsableV4(ip netip.Addr) bool {
|
||||
return false
|
||||
}
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return hostinfo.GetEnvType() == hostinfo.AWSLambda
|
||||
switch hostinfo.GetEnvType() {
|
||||
case hostinfo.AWSLambda:
|
||||
return true
|
||||
case hostinfo.AzureAppService:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func CheckIPForwarding(routes []netip.Prefix, state *interfaces.State) (warn, er
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
const kbLink = "\nSee https://tailscale.com/kb/1104/enable-ip-forwarding/"
|
||||
const kbLink = "\nSee https://tailscale.com/s/ip-forwarding"
|
||||
if state == nil {
|
||||
var err error
|
||||
state, err = interfaces.GetState()
|
||||
|
||||
@@ -14,9 +14,24 @@ import (
|
||||
"tailscale.com/net/interfaces"
|
||||
)
|
||||
|
||||
// SockStats contains statistics for sockets instrumented with the
|
||||
// WithSockStats() function, along with the interfaces that we have
|
||||
// per-interface statistics for.
|
||||
type SockStats struct {
|
||||
Stats map[Label]SockStat
|
||||
Interfaces []string
|
||||
Stats map[Label]SockStat
|
||||
Interfaces []string
|
||||
CurrentInterfaceCellular bool
|
||||
}
|
||||
|
||||
// SockStat contains the sent and received bytes for a socket instrumented with
|
||||
// the WithSockStats() function. The bytes are also broken down by interface,
|
||||
// though this may be a subset of the total if interfaces were added after the
|
||||
// instrumented socket was created.
|
||||
type SockStat struct {
|
||||
TxBytes uint64
|
||||
RxBytes uint64
|
||||
TxBytesByInterface map[string]uint64
|
||||
RxBytesByInterface map[string]uint64
|
||||
}
|
||||
|
||||
// Label is an identifier for a socket that stats are collected for. A finite
|
||||
@@ -41,21 +56,38 @@ const (
|
||||
LabelMagicsockConnUDP6 Label = 9 // wgengine/magicsock/magicsock.go
|
||||
)
|
||||
|
||||
type SockStat struct {
|
||||
TxBytes int64
|
||||
RxBytes int64
|
||||
TxBytesByInterface map[string]int64
|
||||
RxBytesByInterface map[string]int64
|
||||
}
|
||||
|
||||
// WithSockStats instruments a context so that sockets created with it will
|
||||
// have their statistics collected.
|
||||
func WithSockStats(ctx context.Context, label Label) context.Context {
|
||||
return withSockStats(ctx, label)
|
||||
}
|
||||
|
||||
// Get returns the current socket statistics.
|
||||
func Get() *SockStats {
|
||||
return get()
|
||||
}
|
||||
|
||||
// ValidationSockStats contains external validation numbers for sockets
|
||||
// instrumented with WithSockStats. It may be a subset of the all sockets,
|
||||
// depending on what externa measurement mechanisms the platform supports.
|
||||
type ValidationSockStats struct {
|
||||
Stats map[Label]ValidationSockStat
|
||||
}
|
||||
|
||||
// ValidationSockStat contains the validation bytes for a socket instrumented
|
||||
// with WithSockStats.
|
||||
type ValidationSockStat struct {
|
||||
TxBytes uint64
|
||||
RxBytes uint64
|
||||
}
|
||||
|
||||
// GetWithValidation is a variant of GetWith that returns both the current stats
|
||||
// and external validation numbers for the stats. It is more expensive than
|
||||
// Get and should be used in debug interfaces only.
|
||||
func GetWithValidation() (*SockStats, *ValidationSockStats) {
|
||||
return get(), getValidation()
|
||||
}
|
||||
|
||||
// LinkMonitor is the interface for the parts of wgengine/mointor's Mon that we
|
||||
// need, to avoid the dependency.
|
||||
type LinkMonitor interface {
|
||||
@@ -63,6 +95,8 @@ type LinkMonitor interface {
|
||||
RegisterChangeCallback(interfaces.ChangeFunc) (unregister func())
|
||||
}
|
||||
|
||||
// SetLinkMonitor configures the sockstats package to monitor the active
|
||||
// interface, so that per-interface stats can be collected.
|
||||
func SetLinkMonitor(lm LinkMonitor) {
|
||||
setLinkMonitor(lm)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const IsAvailable = false
|
||||
|
||||
func withSockStats(ctx context.Context, label Label) context.Context {
|
||||
return ctx
|
||||
}
|
||||
@@ -17,5 +19,9 @@ func get() *SockStats {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getValidation() *ValidationSockStats {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setLinkMonitor(lm LinkMonitor) {
|
||||
}
|
||||
|
||||
@@ -7,22 +7,37 @@ package sockstats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/util/clientmetric"
|
||||
)
|
||||
|
||||
const IsAvailable = true
|
||||
|
||||
type sockStatCounters struct {
|
||||
txBytes, rxBytes atomic.Uint64
|
||||
rxBytesByInterface, txBytesByInterface map[int]*atomic.Uint64
|
||||
|
||||
txBytesMetric, rxBytesMetric, txBytesCellularMetric, rxBytesCellularMetric *clientmetric.Metric
|
||||
|
||||
// Validate counts for TCP sockets by using the TCP_CONNECTION_INFO
|
||||
// getsockopt. We get current counts, as well as save final values when
|
||||
// sockets are closed.
|
||||
validationConn atomic.Pointer[syscall.RawConn]
|
||||
validationTxBytes, validationRxBytes atomic.Uint64
|
||||
}
|
||||
|
||||
var sockStats = struct {
|
||||
// mu protects fields in this group. It should not be held in the per-read/
|
||||
// write callbacks.
|
||||
// mu protects fields in this group (but not the fields within
|
||||
// sockStatCounters). It should not be held in the per-read/write
|
||||
// callbacks.
|
||||
mu sync.Mutex
|
||||
countersByLabel map[Label]*sockStatCounters
|
||||
knownInterfaces map[int]string // interface index -> name
|
||||
@@ -30,11 +45,18 @@ var sockStats = struct {
|
||||
|
||||
// Separate atomic since the current interface is accessed in the per-read/
|
||||
// write callbacks.
|
||||
currentInterface atomic.Uint32
|
||||
currentInterface atomic.Uint32
|
||||
currentInterfaceCellular atomic.Bool
|
||||
|
||||
txBytesMetric, rxBytesMetric, txBytesCellularMetric, rxBytesCellularMetric *clientmetric.Metric
|
||||
}{
|
||||
countersByLabel: make(map[Label]*sockStatCounters),
|
||||
knownInterfaces: make(map[int]string),
|
||||
usedInterfaces: make(map[int]int),
|
||||
countersByLabel: make(map[Label]*sockStatCounters),
|
||||
knownInterfaces: make(map[int]string),
|
||||
usedInterfaces: make(map[int]int),
|
||||
txBytesMetric: clientmetric.NewCounter("sockstats_tx_bytes"),
|
||||
rxBytesMetric: clientmetric.NewCounter("sockstats_rx_bytes"),
|
||||
txBytesCellularMetric: clientmetric.NewCounter("sockstats_tx_bytes_cellular"),
|
||||
rxBytesCellularMetric: clientmetric.NewCounter("sockstats_rx_bytes_cellular"),
|
||||
}
|
||||
|
||||
func withSockStats(ctx context.Context, label Label) context.Context {
|
||||
@@ -43,70 +65,150 @@ func withSockStats(ctx context.Context, label Label) context.Context {
|
||||
counters, ok := sockStats.countersByLabel[label]
|
||||
if !ok {
|
||||
counters = &sockStatCounters{
|
||||
rxBytesByInterface: make(map[int]*atomic.Uint64),
|
||||
txBytesByInterface: make(map[int]*atomic.Uint64),
|
||||
rxBytesByInterface: make(map[int]*atomic.Uint64),
|
||||
txBytesByInterface: make(map[int]*atomic.Uint64),
|
||||
txBytesMetric: clientmetric.NewCounter(fmt.Sprintf("sockstats_tx_bytes_%s", label)),
|
||||
rxBytesMetric: clientmetric.NewCounter(fmt.Sprintf("sockstats_rx_bytes_%s", label)),
|
||||
txBytesCellularMetric: clientmetric.NewCounter(fmt.Sprintf("sockstats_tx_bytes_cellular_%s", label)),
|
||||
rxBytesCellularMetric: clientmetric.NewCounter(fmt.Sprintf("sockstats_rx_bytes_cellular_%s", label)),
|
||||
}
|
||||
for iface := range sockStats.knownInterfaces {
|
||||
counters.rxBytesByInterface[iface] = &atomic.Uint64{}
|
||||
counters.txBytesByInterface[iface] = &atomic.Uint64{}
|
||||
|
||||
// We might be called before setLinkMonitor has been called (and we've
|
||||
// had a chance to populate knownInterfaces). In that case, we'll have
|
||||
// to get the list of interfaces ourselves.
|
||||
if len(sockStats.knownInterfaces) == 0 {
|
||||
if ifaces, err := interfaces.GetList(); err == nil {
|
||||
for _, iface := range ifaces {
|
||||
counters.rxBytesByInterface[iface.Index] = &atomic.Uint64{}
|
||||
counters.txBytesByInterface[iface.Index] = &atomic.Uint64{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for iface := range sockStats.knownInterfaces {
|
||||
counters.rxBytesByInterface[iface] = &atomic.Uint64{}
|
||||
counters.txBytesByInterface[iface] = &atomic.Uint64{}
|
||||
}
|
||||
}
|
||||
sockStats.countersByLabel[label] = counters
|
||||
}
|
||||
|
||||
didCreateTCPConn := func(c syscall.RawConn) {
|
||||
counters.validationConn.Store(&c)
|
||||
}
|
||||
|
||||
willCloseTCPConn := func(c syscall.RawConn) {
|
||||
tx, rx := tcpConnStats(c)
|
||||
counters.validationTxBytes.Add(tx)
|
||||
counters.validationRxBytes.Add(rx)
|
||||
counters.validationConn.Store(nil)
|
||||
}
|
||||
|
||||
// Don't bother adding these hooks if we can't get stats that they end up
|
||||
// collecting.
|
||||
if tcpConnStats == nil {
|
||||
willCloseTCPConn = nil
|
||||
didCreateTCPConn = nil
|
||||
}
|
||||
|
||||
didRead := func(n int) {
|
||||
counters.rxBytes.Add(uint64(n))
|
||||
counters.rxBytesMetric.Add(int64(n))
|
||||
sockStats.rxBytesMetric.Add(int64(n))
|
||||
if currentInterface := int(sockStats.currentInterface.Load()); currentInterface != 0 {
|
||||
if a := counters.rxBytesByInterface[currentInterface]; a != nil {
|
||||
a.Add(uint64(n))
|
||||
}
|
||||
}
|
||||
if sockStats.currentInterfaceCellular.Load() {
|
||||
sockStats.rxBytesCellularMetric.Add(int64(n))
|
||||
counters.rxBytesCellularMetric.Add(int64(n))
|
||||
}
|
||||
}
|
||||
didWrite := func(n int) {
|
||||
counters.txBytes.Add(uint64(n))
|
||||
counters.txBytesMetric.Add(int64(n))
|
||||
sockStats.txBytesMetric.Add(int64(n))
|
||||
if currentInterface := int(sockStats.currentInterface.Load()); currentInterface != 0 {
|
||||
if a := counters.txBytesByInterface[currentInterface]; a != nil {
|
||||
a.Add(uint64(n))
|
||||
}
|
||||
}
|
||||
if sockStats.currentInterfaceCellular.Load() {
|
||||
sockStats.txBytesCellularMetric.Add(int64(n))
|
||||
counters.txBytesCellularMetric.Add(int64(n))
|
||||
}
|
||||
}
|
||||
willOverwrite := func(trace *net.SockTrace) {
|
||||
log.Printf("sockstats: trace %q was overwritten by another", label)
|
||||
}
|
||||
|
||||
return net.WithSockTrace(ctx, &net.SockTrace{
|
||||
DidRead: didRead,
|
||||
DidWrite: didWrite,
|
||||
WillOverwrite: willOverwrite,
|
||||
DidCreateTCPConn: didCreateTCPConn,
|
||||
DidRead: didRead,
|
||||
DidWrite: didWrite,
|
||||
WillOverwrite: willOverwrite,
|
||||
WillCloseTCPConn: willCloseTCPConn,
|
||||
})
|
||||
}
|
||||
|
||||
// tcpConnStats returns the number of bytes sent and received on the
|
||||
// given TCP socket. Its implementation is platform-dependent (or it may not
|
||||
// be available at all).
|
||||
var tcpConnStats func(c syscall.RawConn) (tx, rx uint64)
|
||||
|
||||
func get() *SockStats {
|
||||
sockStats.mu.Lock()
|
||||
defer sockStats.mu.Unlock()
|
||||
|
||||
r := &SockStats{
|
||||
Stats: make(map[Label]SockStat),
|
||||
Interfaces: make([]string, 0, len(sockStats.usedInterfaces)),
|
||||
Stats: make(map[Label]SockStat),
|
||||
Interfaces: make([]string, 0, len(sockStats.usedInterfaces)),
|
||||
CurrentInterfaceCellular: sockStats.currentInterfaceCellular.Load(),
|
||||
}
|
||||
for iface := range sockStats.usedInterfaces {
|
||||
r.Interfaces = append(r.Interfaces, sockStats.knownInterfaces[iface])
|
||||
}
|
||||
|
||||
for label, counters := range sockStats.countersByLabel {
|
||||
r.Stats[label] = SockStat{
|
||||
TxBytes: int64(counters.txBytes.Load()),
|
||||
RxBytes: int64(counters.rxBytes.Load()),
|
||||
TxBytesByInterface: make(map[string]int64),
|
||||
RxBytesByInterface: make(map[string]int64),
|
||||
s := SockStat{
|
||||
TxBytes: counters.txBytes.Load(),
|
||||
RxBytes: counters.rxBytes.Load(),
|
||||
TxBytesByInterface: make(map[string]uint64),
|
||||
RxBytesByInterface: make(map[string]uint64),
|
||||
}
|
||||
for iface, a := range counters.rxBytesByInterface {
|
||||
ifName := sockStats.knownInterfaces[iface]
|
||||
r.Stats[label].RxBytesByInterface[ifName] = int64(a.Load())
|
||||
s.RxBytesByInterface[ifName] = a.Load()
|
||||
}
|
||||
for iface, a := range counters.txBytesByInterface {
|
||||
ifName := sockStats.knownInterfaces[iface]
|
||||
r.Stats[label].TxBytesByInterface[ifName] = int64(a.Load())
|
||||
s.TxBytesByInterface[ifName] = a.Load()
|
||||
}
|
||||
r.Stats[label] = s
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func getValidation() *ValidationSockStats {
|
||||
sockStats.mu.Lock()
|
||||
defer sockStats.mu.Unlock()
|
||||
|
||||
r := &ValidationSockStats{
|
||||
Stats: make(map[Label]ValidationSockStat),
|
||||
}
|
||||
|
||||
for label, counters := range sockStats.countersByLabel {
|
||||
s := ValidationSockStat{
|
||||
TxBytes: counters.validationTxBytes.Load(),
|
||||
RxBytes: counters.validationRxBytes.Load(),
|
||||
}
|
||||
if c := counters.validationConn.Load(); c != nil && tcpConnStats != nil {
|
||||
tx, rx := tcpConnStats(*c)
|
||||
s.TxBytes += tx
|
||||
s.RxBytes += rx
|
||||
}
|
||||
r.Stats[label] = s
|
||||
}
|
||||
|
||||
return r
|
||||
@@ -125,6 +227,7 @@ func setLinkMonitor(lm LinkMonitor) {
|
||||
if ifName := state.DefaultRouteInterface; ifName != "" {
|
||||
ifIndex := state.Interface[ifName].Index
|
||||
sockStats.currentInterface.Store(uint32(ifIndex))
|
||||
sockStats.currentInterfaceCellular.Store(isLikelyCellularInterface(ifName))
|
||||
sockStats.usedInterfaces[ifIndex] = 1
|
||||
}
|
||||
|
||||
@@ -141,10 +244,18 @@ func setLinkMonitor(lm LinkMonitor) {
|
||||
if _, ok := sockStats.knownInterfaces[ifIndex]; ok {
|
||||
sockStats.currentInterface.Store(uint32(ifIndex))
|
||||
sockStats.usedInterfaces[ifIndex] = 1
|
||||
sockStats.currentInterfaceCellular.Store(isLikelyCellularInterface(ifName))
|
||||
} else {
|
||||
sockStats.currentInterface.Store(0)
|
||||
sockStats.currentInterfaceCellular.Store(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func isLikelyCellularInterface(ifName string) bool {
|
||||
return strings.HasPrefix(ifName, "rmnet") || // Android
|
||||
strings.HasPrefix(ifName, "ww") || // systemd naming scheme for WWAN
|
||||
strings.HasPrefix(ifName, "pdp") // iOS
|
||||
}
|
||||
|
||||
30
net/sockstats/sockstats_tsgo_darwin.go
Normal file
30
net/sockstats/sockstats_tsgo_darwin.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build tailscale_go && (darwin || ios)
|
||||
|
||||
package sockstats
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func init() {
|
||||
tcpConnStats = darwinTcpConnStats
|
||||
}
|
||||
|
||||
func darwinTcpConnStats(c syscall.RawConn) (tx, rx uint64) {
|
||||
c.Control(func(fd uintptr) {
|
||||
if rawInfo, err := unix.GetsockoptTCPConnectionInfo(
|
||||
int(fd),
|
||||
unix.IPPROTO_TCP,
|
||||
unix.TCP_CONNECTION_INFO,
|
||||
); err == nil {
|
||||
tx = uint64(rawInfo.Txbytes)
|
||||
rx = uint64(rawInfo.Rxbytes)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -26,7 +26,7 @@ var chromeOSRange oncePrefix
|
||||
|
||||
// CGNATRange returns the Carrier Grade NAT address range that
|
||||
// is the superset range that Tailscale assigns out of.
|
||||
// See https://tailscale.com/kb/1015/100.x-addresses.
|
||||
// See https://tailscale.com/s/cgnat
|
||||
// Note that Tailscale does not assign out of the ChromeOSVMRange.
|
||||
func CGNATRange() netip.Prefix {
|
||||
cgnatRange.Do(func() { mustPrefix(&cgnatRange.v, "100.64.0.0/10") })
|
||||
|
||||
@@ -119,6 +119,12 @@ main() {
|
||||
VERSION="bionic"
|
||||
APT_KEY_TYPE="legacy"
|
||||
;;
|
||||
pureos)
|
||||
OS="debian"
|
||||
PACKAGETYPE="apt"
|
||||
VERSION="bullseye"
|
||||
APT_KEY_TYPE="keyring"
|
||||
;;
|
||||
raspbian)
|
||||
OS="$ID"
|
||||
VERSION="$VERSION_CODENAME"
|
||||
@@ -347,7 +353,9 @@ main() {
|
||||
fi
|
||||
;;
|
||||
amazon-linux)
|
||||
if [ "$VERSION" != "2" ]
|
||||
if [ "$VERSION" != "2" ] && \
|
||||
[ "$VERSION" != "2022" ] && \
|
||||
[ "$VERSION" != "2023" ]
|
||||
then
|
||||
OS_UNSUPPORTED=1
|
||||
fi
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sshContext is the context.Context implementation we use for SSH
|
||||
// that adds a CloseWithError method. Otherwise it's just a normalish
|
||||
// Context.
|
||||
type sshContext struct {
|
||||
underlying context.Context
|
||||
cancel context.CancelFunc // cancels underlying
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newSSHContext(ctx context.Context) *sshContext {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &sshContext{underlying: ctx, cancel: cancel}
|
||||
}
|
||||
|
||||
func (ctx *sshContext) CloseWithError(err error) {
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
if ctx.closed {
|
||||
return
|
||||
}
|
||||
ctx.closed = true
|
||||
ctx.err = err
|
||||
ctx.cancel()
|
||||
}
|
||||
|
||||
func (ctx *sshContext) Err() error {
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
return ctx.err
|
||||
}
|
||||
|
||||
func (ctx *sshContext) Done() <-chan struct{} { return ctx.underlying.Done() }
|
||||
func (ctx *sshContext) Deadline() (deadline time.Time, ok bool) { return }
|
||||
func (ctx *sshContext) Value(k any) any { return ctx.underlying.Value(k) }
|
||||
|
||||
// userVisibleError is a wrapper around an error that implements
|
||||
// SSHTerminationError, so msg is written to their session.
|
||||
type userVisibleError struct {
|
||||
msg string
|
||||
error
|
||||
}
|
||||
|
||||
func (ue userVisibleError) SSHTerminationMessage() string { return ue.msg }
|
||||
|
||||
// SSHTerminationError is implemented by errors that terminate an SSH
|
||||
// session and should be written to user's sessions.
|
||||
type SSHTerminationError interface {
|
||||
error
|
||||
SSHTerminationMessage() string
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (ss *sshSession) newIncubatorCommand() (cmd *exec.Cmd) {
|
||||
ci := ss.conn.info
|
||||
gids := strings.Join(ss.conn.userGroupIDs, ",")
|
||||
remoteUser := ci.uprof.LoginName
|
||||
if len(ci.node.Tags) > 0 {
|
||||
if ci.node.IsTagged() {
|
||||
remoteUser = strings.Join(ci.node.Tags, ",")
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ func beIncubator(args []string) error {
|
||||
if err == nil && sessionCloser != nil {
|
||||
defer sessionCloser()
|
||||
}
|
||||
|
||||
var groupIDs []int
|
||||
for _, g := range strings.Split(ia.groups, ",") {
|
||||
gid, err := strconv.ParseInt(g, 10, 32)
|
||||
@@ -244,22 +245,10 @@ func beIncubator(args []string) error {
|
||||
groupIDs = append(groupIDs, int(gid))
|
||||
}
|
||||
|
||||
if err := setGroups(groupIDs); err != nil {
|
||||
if err := dropPrivileges(logf, int(ia.uid), ia.gid, groupIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if egid := os.Getegid(); egid != ia.gid {
|
||||
if err := syscall.Setgid(int(ia.gid)); err != nil {
|
||||
logf(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if euid != ia.uid {
|
||||
// Switch users if required before starting the desired process.
|
||||
if err := syscall.Setuid(int(ia.uid)); err != nil {
|
||||
logf(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if ia.isSFTP {
|
||||
logf("handling sftp")
|
||||
|
||||
@@ -304,6 +293,108 @@ func beIncubator(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(andrew-d): verify that this works in more configurations before
|
||||
// enabling by default.
|
||||
const assertDropPrivileges = false
|
||||
|
||||
// dropPrivileges contains all the logic for dropping privileges to a different
|
||||
// UID, GID, and set of supplementary groups. This function is
|
||||
// security-sensitive and ordering-dependent; please be very cautious if/when
|
||||
// refactoring.
|
||||
//
|
||||
// WARNING: if you change this function, you *MUST* run the TestDropPrivileges
|
||||
// test in this package as root on at least Linux, FreeBSD and Darwin. This can
|
||||
// be done by running:
|
||||
//
|
||||
// go test -c ./ssh/tailssh/ && sudo ./tailssh.test -test.v -test.run TestDropPrivileges
|
||||
func dropPrivileges(logf logger.Logf, wantUid, wantGid int, supplementaryGroups []int) error {
|
||||
fatalf := func(format string, args ...any) {
|
||||
logf(format, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
euid := os.Geteuid()
|
||||
egid := os.Getegid()
|
||||
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" {
|
||||
// On FreeBSD and Darwin, the first entry returned from the
|
||||
// getgroups(2) syscall is the egid, and changing it with
|
||||
// setgroups(2) changes the egid of the process. This is
|
||||
// technically a violation of the POSIX standard; see the
|
||||
// following article for more detail:
|
||||
// https://www.usenix.org/system/files/login/articles/325-tsafrir.pdf
|
||||
//
|
||||
// In this case, we add an entry at the beginning of the
|
||||
// groupIDs list containing the expected gid if it's not
|
||||
// already there, which modifies the egid and additional groups
|
||||
// as one unit.
|
||||
if len(supplementaryGroups) == 0 || supplementaryGroups[0] != wantGid {
|
||||
supplementaryGroups = append([]int{wantGid}, supplementaryGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
if err := setGroups(supplementaryGroups); err != nil {
|
||||
return err
|
||||
}
|
||||
if egid != wantGid {
|
||||
// On FreeBSD and Darwin, we may have already called the
|
||||
// equivalent of setegid(wantGid) via the call to setGroups,
|
||||
// above. However, per the manpage, setgid(getegid()) is an
|
||||
// allowed operation regardless of privilege level.
|
||||
//
|
||||
// FreeBSD:
|
||||
// The setgid() system call is permitted if the specified ID
|
||||
// is equal to the real group ID or the effective group ID
|
||||
// of the process, or if the effective user ID is that of
|
||||
// the super user.
|
||||
//
|
||||
// Darwin:
|
||||
// The setgid() function is permitted if the effective
|
||||
// user ID is that of the super user, or if the specified
|
||||
// group ID is the same as the effective group ID. If
|
||||
// not, but the specified group ID is the same as the real
|
||||
// group ID, setgid() will set the effective group ID to
|
||||
// the real group ID.
|
||||
if err := syscall.Setgid(wantGid); err != nil {
|
||||
fatalf("Setgid(%d): %v", wantGid, err)
|
||||
}
|
||||
}
|
||||
if euid != wantUid {
|
||||
// Switch users if required before starting the desired process.
|
||||
if err := syscall.Setuid(wantUid); err != nil {
|
||||
fatalf("Setuid(%d): %v", wantUid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// If we changed either the UID or GID, defensively assert that we
|
||||
// cannot reset the it back to our original values, and that the
|
||||
// current egid/euid are the expected values after we change
|
||||
// everything; if not, we exit the process.
|
||||
if assertDropPrivileges {
|
||||
if egid != wantGid {
|
||||
if err := syscall.Setegid(egid); err == nil {
|
||||
fatalf("unexpectedly able to set egid back to %d", egid)
|
||||
}
|
||||
}
|
||||
if euid != wantUid {
|
||||
if err := syscall.Seteuid(euid); err == nil {
|
||||
fatalf("unexpectedly able to set euid back to %d", euid)
|
||||
}
|
||||
}
|
||||
|
||||
if got := os.Getegid(); got != wantGid {
|
||||
fatalf("got egid=%d, want %d", got, wantGid)
|
||||
}
|
||||
if got := os.Geteuid(); got != wantUid {
|
||||
fatalf("got euid=%d, want %d", got, wantUid)
|
||||
}
|
||||
|
||||
// TODO(andrew-d): assert that our supplementary groups are correct
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchProcess launches an incubator process for the provided session.
|
||||
// It is responsible for configuring the process execution environment.
|
||||
// The caller can wait for the process to exit by calling cmd.Wait().
|
||||
|
||||
295
ssh/tailssh/privs_test.go
Normal file
295
ssh/tailssh/privs_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func TestDropPrivileges(t *testing.T) {
|
||||
type SubprocInput struct {
|
||||
UID int
|
||||
GID int
|
||||
AdditionalGroups []int
|
||||
}
|
||||
type SubprocOutput struct {
|
||||
UID int
|
||||
GID int
|
||||
EUID int
|
||||
EGID int
|
||||
AdditionalGroups []int
|
||||
}
|
||||
|
||||
if v := os.Getenv("TS_TEST_DROP_PRIVILEGES_CHILD"); v != "" {
|
||||
t.Logf("in child process")
|
||||
|
||||
var input SubprocInput
|
||||
if err := json.Unmarshal([]byte(v), &input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Get a handle to our provided JSON file before dropping privs.
|
||||
f := os.NewFile(3, "out.json")
|
||||
|
||||
// We're in our subprocess; actually drop privileges now.
|
||||
dropPrivileges(t.Logf, input.UID, input.GID, input.AdditionalGroups)
|
||||
|
||||
additional, _ := syscall.Getgroups()
|
||||
|
||||
// Print our IDs
|
||||
json.NewEncoder(f).Encode(SubprocOutput{
|
||||
UID: os.Getuid(),
|
||||
GID: os.Getgid(),
|
||||
EUID: os.Geteuid(),
|
||||
EGID: os.Getegid(),
|
||||
AdditionalGroups: additional,
|
||||
})
|
||||
|
||||
// Close output file to ensure that it's flushed to disk before we exit
|
||||
f.Close()
|
||||
|
||||
// Always exit the process now that we have a different
|
||||
// UID/GID/etc.; we don't want the Go test framework to try and
|
||||
// clean anything up, since it might no longer have access.
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if os.Getuid() != 0 {
|
||||
t.Skip("test only works when run as root")
|
||||
}
|
||||
|
||||
rerunSelf := func(t *testing.T, input SubprocInput) []byte {
|
||||
fpath := filepath.Join(t.TempDir(), "out.json")
|
||||
outf, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inputb, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.v", "-test.run", "^"+regexp.QuoteMeta(t.Name())+"$")
|
||||
cmd.Env = append(os.Environ(), "TS_TEST_DROP_PRIVILEGES_CHILD="+string(inputb))
|
||||
cmd.ExtraFiles = []*os.File{outf}
|
||||
cmd.Stdout = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: "))
|
||||
cmd.Stderr = logger.FuncWriter(logger.WithPrefix(t.Logf, "child: "))
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outf.Close()
|
||||
|
||||
jj, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return jj
|
||||
}
|
||||
|
||||
// We want to ensure we're not colliding with existing users; find some
|
||||
// unused UIDs and GIDs for the tests we run.
|
||||
uid1 := findUnusedUID(t)
|
||||
gid1 := findUnusedGID(t)
|
||||
gid2 := findUnusedGID(t, gid1)
|
||||
gid3 := findUnusedGID(t, gid1, gid2)
|
||||
|
||||
// For some tests, we want a UID/GID pair with the same numerical
|
||||
// value; this finds one.
|
||||
uidgid1 := findUnusedUIDGID(t, uid1, gid1, gid2, gid3)
|
||||
|
||||
t.Logf("uid1=%d gid1=%d gid2=%d gid3=%d uidgid1=%d",
|
||||
uid1, gid1, gid2, gid3, uidgid1)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
uid int
|
||||
gid int
|
||||
additionalGroups []int
|
||||
}{
|
||||
{
|
||||
name: "all_different_values",
|
||||
uid: uid1,
|
||||
gid: gid1,
|
||||
additionalGroups: []int{gid2, gid3},
|
||||
},
|
||||
{
|
||||
name: "no_additional_groups",
|
||||
uid: uid1,
|
||||
gid: gid1,
|
||||
additionalGroups: []int{},
|
||||
},
|
||||
// This is a regression test for the following bug, triggered
|
||||
// on Darwin & FreeBSD:
|
||||
// https://github.com/tailscale/tailscale/issues/7616
|
||||
{
|
||||
name: "same_values",
|
||||
uid: uidgid1,
|
||||
gid: uidgid1,
|
||||
additionalGroups: []int{uidgid1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
subprocOut := rerunSelf(t, SubprocInput{
|
||||
UID: tt.uid,
|
||||
GID: tt.gid,
|
||||
AdditionalGroups: tt.additionalGroups,
|
||||
})
|
||||
|
||||
var out SubprocOutput
|
||||
if err := json.Unmarshal(subprocOut, &out); err != nil {
|
||||
t.Logf("%s", subprocOut)
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("output: %+v", out)
|
||||
|
||||
if out.UID != tt.uid {
|
||||
t.Errorf("got uid %d; want %d", out.UID, tt.uid)
|
||||
}
|
||||
if out.GID != tt.gid {
|
||||
t.Errorf("got gid %d; want %d", out.GID, tt.gid)
|
||||
}
|
||||
if out.EUID != tt.uid {
|
||||
t.Errorf("got euid %d; want %d", out.EUID, tt.uid)
|
||||
}
|
||||
if out.EGID != tt.gid {
|
||||
t.Errorf("got egid %d; want %d", out.EGID, tt.gid)
|
||||
}
|
||||
|
||||
// On FreeBSD and Darwin, the set of additional groups
|
||||
// is prefixed with the egid; handle that case by
|
||||
// modifying our expected set.
|
||||
wantGroups := make(map[int]bool)
|
||||
for _, id := range tt.additionalGroups {
|
||||
wantGroups[id] = true
|
||||
}
|
||||
if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" {
|
||||
wantGroups[tt.gid] = true
|
||||
}
|
||||
|
||||
gotGroups := make(map[int]bool)
|
||||
for _, id := range out.AdditionalGroups {
|
||||
gotGroups[id] = true
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotGroups, wantGroups) {
|
||||
t.Errorf("got additional groups %+v; want %+v", gotGroups, wantGroups)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func findUnusedUID(t *testing.T, not ...int) int {
|
||||
for i := 1000; i < 65535; i++ {
|
||||
// Skip UIDs that might be valid
|
||||
if maybeValidUID(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip UIDs that we're avoiding
|
||||
if slices.Contains(not, i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Not a valid UID, not one we're avoiding... all good!
|
||||
return i
|
||||
}
|
||||
|
||||
t.Fatalf("unable to find an unused UID")
|
||||
return -1
|
||||
}
|
||||
|
||||
func findUnusedGID(t *testing.T, not ...int) int {
|
||||
for i := 1000; i < 65535; i++ {
|
||||
if maybeValidGID(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip GIDs that we're avoiding
|
||||
if slices.Contains(not, i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Not a valid GID, not one we're avoiding... all good!
|
||||
return i
|
||||
}
|
||||
|
||||
t.Fatalf("unable to find an unused GID")
|
||||
return -1
|
||||
}
|
||||
|
||||
func findUnusedUIDGID(t *testing.T, not ...int) int {
|
||||
for i := 1000; i < 65535; i++ {
|
||||
if maybeValidUID(i) || maybeValidGID(i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip IDs that we're avoiding
|
||||
if slices.Contains(not, i) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Not a valid ID, not one we're avoiding... all good!
|
||||
return i
|
||||
}
|
||||
|
||||
t.Fatalf("unable to find an unused UID/GID pair")
|
||||
return -1
|
||||
}
|
||||
|
||||
func maybeValidUID(id int) bool {
|
||||
_, err := user.LookupId(strconv.Itoa(id))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
var u1 user.UnknownUserIdError
|
||||
if errors.As(err, &u1) {
|
||||
return false
|
||||
}
|
||||
var u2 user.UnknownUserError
|
||||
if errors.As(err, &u2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Some other error; might be valid
|
||||
return true
|
||||
}
|
||||
|
||||
func maybeValidGID(id int) bool {
|
||||
_, err := user.LookupGroupId(strconv.Itoa(id))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
var u1 user.UnknownGroupIdError
|
||||
if errors.As(err, &u1) {
|
||||
return false
|
||||
}
|
||||
var u2 user.UnknownGroupError
|
||||
if errors.As(err, &u2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Some other error; might be valid
|
||||
return true
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"tailscale.com/ipn/ipnlocal"
|
||||
"tailscale.com/logtail/backoff"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/net/tsdial"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tempfork/gliderlabs/ssh"
|
||||
"tailscale.com/types/logger"
|
||||
@@ -62,7 +63,7 @@ type ipnLocalBackend interface {
|
||||
NetMap() *netmap.NetworkMap
|
||||
WhoIs(ipp netip.AddrPort) (n *tailcfg.Node, u tailcfg.UserProfile, ok bool)
|
||||
DoNoiseRequest(req *http.Request) (*http.Response, error)
|
||||
TailscaleVarRoot() string
|
||||
Dialer() *tsdial.Dialer
|
||||
}
|
||||
|
||||
type server struct {
|
||||
@@ -77,11 +78,33 @@ type server struct {
|
||||
|
||||
// mu protects the following
|
||||
mu sync.Mutex
|
||||
httpc *http.Client // for calling out to peers.
|
||||
activeConns map[*conn]bool // set; value is always true
|
||||
fetchPublicKeysCache map[string]pubKeyCacheEntry // by https URL
|
||||
shutdownCalled bool
|
||||
}
|
||||
|
||||
// sessionRecordingClient returns an http.Client that uses srv.lb.Dialer() to
|
||||
// dial connections. This is used to make requests to the session recording
|
||||
// server to upload session recordings.
|
||||
func (srv *server) sessionRecordingClient() *http.Client {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
if srv.httpc != nil {
|
||||
return srv.httpc
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return srv.lb.Dialer().UserDial(ctx, network, addr)
|
||||
}
|
||||
srv.httpc = &http.Client{
|
||||
Transport: tr,
|
||||
}
|
||||
return srv.httpc
|
||||
}
|
||||
|
||||
func (srv *server) now() time.Time {
|
||||
if srv != nil && srv.timeNow != nil {
|
||||
return srv.timeNow()
|
||||
@@ -787,7 +810,8 @@ type sshSession struct {
|
||||
sharedID string // ID that's shared with control
|
||||
logf logger.Logf
|
||||
|
||||
ctx *sshContext // implements context.Context
|
||||
ctx context.Context
|
||||
cancelCtx context.CancelCauseFunc
|
||||
conn *conn
|
||||
agentListener net.Listener // non-nil if agent-forwarding requested+allowed
|
||||
|
||||
@@ -812,12 +836,14 @@ func (ss *sshSession) vlogf(format string, args ...interface{}) {
|
||||
func (c *conn) newSSHSession(s ssh.Session) *sshSession {
|
||||
sharedID := fmt.Sprintf("sess-%s-%02x", c.srv.now().UTC().Format("20060102T150405"), randBytes(5))
|
||||
c.logf("starting session: %v", sharedID)
|
||||
ctx, cancel := context.WithCancelCause(s.Context())
|
||||
return &sshSession{
|
||||
Session: s,
|
||||
sharedID: sharedID,
|
||||
ctx: newSSHContext(s.Context()),
|
||||
conn: c,
|
||||
logf: logger.WithPrefix(c.srv.logf, "ssh-session("+sharedID+"): "),
|
||||
Session: s,
|
||||
sharedID: sharedID,
|
||||
ctx: ctx,
|
||||
cancelCtx: cancel,
|
||||
conn: c,
|
||||
logf: logger.WithPrefix(c.srv.logf, "ssh-session("+sharedID+"): "),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,7 +870,7 @@ func (c *conn) checkStillValid() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, s := range c.sessions {
|
||||
s.ctx.CloseWithError(userVisibleError{
|
||||
s.cancelCtx(userVisibleError{
|
||||
fmt.Sprintf("Access revoked.\r\n"),
|
||||
context.Canceled,
|
||||
})
|
||||
@@ -897,7 +923,7 @@ func (ss *sshSession) killProcessOnContextDone() {
|
||||
// Either the process has already exited, in which case this does nothing.
|
||||
// Or, the process is still running in which case this will kill it.
|
||||
ss.exitOnce.Do(func() {
|
||||
err := ss.ctx.Err()
|
||||
err := context.Cause(ss.ctx)
|
||||
if serr, ok := err.(SSHTerminationError); ok {
|
||||
msg := serr.SSHTerminationMessage()
|
||||
if msg != "" {
|
||||
@@ -984,12 +1010,6 @@ func (ss *sshSession) handleSSHAgentForwarding(s ssh.Session, lu *user.User) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordSSH is a temporary dev knob to test the SSH recording
|
||||
// functionality and support off-node streaming.
|
||||
//
|
||||
// TODO(bradfitz,maisem): move this to SSHPolicy.
|
||||
var recordSSH = envknob.RegisterBool("TS_DEBUG_LOG_SSH")
|
||||
|
||||
// run is the entrypoint for a newly accepted SSH session.
|
||||
//
|
||||
// It handles ss once it's been accepted and determined
|
||||
@@ -997,7 +1017,7 @@ var recordSSH = envknob.RegisterBool("TS_DEBUG_LOG_SSH")
|
||||
func (ss *sshSession) run() {
|
||||
metricActiveSessions.Add(1)
|
||||
defer metricActiveSessions.Add(-1)
|
||||
defer ss.ctx.CloseWithError(errSessionDone)
|
||||
defer ss.cancelCtx(errSessionDone)
|
||||
|
||||
if attached := ss.conn.srv.attachSessionToConnIfNotShutdown(ss); !attached {
|
||||
fmt.Fprintf(ss, "Tailscale SSH is shutting down\r\n")
|
||||
@@ -1011,7 +1031,7 @@ func (ss *sshSession) run() {
|
||||
|
||||
if ss.conn.finalAction.SessionDuration != 0 {
|
||||
t := time.AfterFunc(ss.conn.finalAction.SessionDuration, func() {
|
||||
ss.ctx.CloseWithError(userVisibleError{
|
||||
ss.cancelCtx(userVisibleError{
|
||||
fmt.Sprintf("Session timeout of %v elapsed.", ss.conn.finalAction.SessionDuration),
|
||||
context.DeadlineExceeded,
|
||||
})
|
||||
@@ -1045,7 +1065,12 @@ func (ss *sshSession) run() {
|
||||
var err error
|
||||
rec, err = ss.startNewRecording()
|
||||
if err != nil {
|
||||
fmt.Fprintf(ss, "can't start new recording\r\n")
|
||||
var uve userVisibleError
|
||||
if errors.As(err, &uve) {
|
||||
fmt.Fprintf(ss, "%s\r\n", uve)
|
||||
} else {
|
||||
fmt.Fprintf(ss, "can't start new recording\r\n")
|
||||
}
|
||||
ss.logf("startNewRecording: %v", err)
|
||||
ss.Exit(1)
|
||||
return
|
||||
@@ -1057,6 +1082,13 @@ func (ss *sshSession) run() {
|
||||
err := ss.launchProcess()
|
||||
if err != nil {
|
||||
logf("start failed: %v", err.Error())
|
||||
if errors.Is(err, context.Canceled) {
|
||||
err := context.Cause(ss.ctx)
|
||||
var uve userVisibleError
|
||||
if errors.As(err, &uve) {
|
||||
fmt.Fprintf(ss, "%s\r\n", uve)
|
||||
}
|
||||
}
|
||||
ss.Exit(1)
|
||||
return
|
||||
}
|
||||
@@ -1066,7 +1098,7 @@ func (ss *sshSession) run() {
|
||||
defer ss.stdin.Close()
|
||||
if _, err := io.Copy(rec.writer("i", ss.stdin), ss); err != nil {
|
||||
logf("stdin copy: %v", err)
|
||||
ss.ctx.CloseWithError(err)
|
||||
ss.cancelCtx(err)
|
||||
}
|
||||
}()
|
||||
var openOutputStreams atomic.Int32
|
||||
@@ -1080,7 +1112,7 @@ func (ss *sshSession) run() {
|
||||
_, err := io.Copy(rec.writer("o", ss), ss.stdout)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
logf("stdout copy: %v", err)
|
||||
ss.ctx.CloseWithError(err)
|
||||
ss.cancelCtx(err)
|
||||
}
|
||||
if openOutputStreams.Add(-1) == 0 {
|
||||
ss.CloseWrite()
|
||||
@@ -1122,12 +1154,19 @@ func (ss *sshSession) run() {
|
||||
return
|
||||
}
|
||||
|
||||
// recorders returns the list of recorders to use for this session.
|
||||
// If the final action has a non-empty list of recorders, that list is
|
||||
// returned. Otherwise, the list of recorders from the initial action
|
||||
// is returned.
|
||||
func (ss *sshSession) recorders() []netip.AddrPort {
|
||||
if len(ss.conn.finalAction.Recorders) > 0 {
|
||||
return ss.conn.finalAction.Recorders
|
||||
}
|
||||
return ss.conn.action0.Recorders
|
||||
}
|
||||
|
||||
func (ss *sshSession) shouldRecord() bool {
|
||||
// for now only record pty sessions
|
||||
// TODO(bradfitz,maisem): make configurable on SSHPolicy and
|
||||
// support recording non-pty stuff too.
|
||||
_, _, isPtyReq := ss.Pty()
|
||||
return recordSSH() && isPtyReq
|
||||
return len(ss.recorders()) > 0
|
||||
}
|
||||
|
||||
type sshConnInfo struct {
|
||||
@@ -1309,11 +1348,67 @@ func randBytes(n int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// CastHeader is the header of an asciinema file.
|
||||
type CastHeader struct {
|
||||
// Version is the asciinema file format version.
|
||||
Version int `json:"version"`
|
||||
|
||||
// Width is the terminal width in characters.
|
||||
// It is non-zero for Pty sessions.
|
||||
Width int `json:"width"`
|
||||
|
||||
// Height is the terminal height in characters.
|
||||
// It is non-zero for Pty sessions.
|
||||
Height int `json:"height"`
|
||||
|
||||
// Timestamp is the unix timestamp of when the recording started.
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
|
||||
// Env is the environment variables of the session.
|
||||
// Only "TERM" is set (2023-03-22).
|
||||
Env map[string]string `json:"env"`
|
||||
|
||||
// Command is the command that was executed.
|
||||
// Typically empty for shell sessions.
|
||||
Command string `json:"command,omitempty"`
|
||||
|
||||
// Tailscale-specific fields:
|
||||
// SrcNode is the FQDN of the node originating the connection.
|
||||
// It is also the MagicDNS name for the node.
|
||||
// It does not have a trailing dot.
|
||||
// e.g. "host.tail-scale.ts.net"
|
||||
SrcNode string `json:"srcNode"`
|
||||
|
||||
// SrcNodeID is the node ID of the node originating the connection.
|
||||
SrcNodeID tailcfg.StableNodeID `json:"srcNodeID"`
|
||||
|
||||
// SrcNodeTags is the list of tags on the node originating the connection (if any).
|
||||
SrcNodeTags []string `json:"srcNodeTags,omitempty"`
|
||||
|
||||
// SrcNodeUserID is the user ID of the node originating the connection (if not tagged).
|
||||
SrcNodeUserID tailcfg.UserID `json:"srcNodeUserID,omitempty"` // if not tagged
|
||||
|
||||
// SrcNodeUser is the LoginName of the node originating the connection (if not tagged).
|
||||
SrcNodeUser string `json:"srcNodeUser,omitempty"`
|
||||
|
||||
// SSHUser is the username as presented by the client.
|
||||
SSHUser string `json:"sshUser"` // as presented by the client
|
||||
|
||||
// LocalUser is the effective username on the server.
|
||||
LocalUser string `json:"localUser"`
|
||||
}
|
||||
|
||||
// startNewRecording starts a new SSH session recording.
|
||||
//
|
||||
// It writes an asciinema file to
|
||||
// $TAILSCALE_VAR_ROOT/ssh-sessions/ssh-session-<unixtime>-*.cast.
|
||||
func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
||||
recorders := ss.recorders()
|
||||
if len(recorders) == 0 {
|
||||
return nil, errors.New("no recorders configured")
|
||||
}
|
||||
recorder := recorders[0]
|
||||
if len(recorders) > 1 {
|
||||
ss.logf("warning: multiple recorders configured, using first one: %v", recorder)
|
||||
}
|
||||
|
||||
var w ssh.Window
|
||||
if ptyReq, _, isPtyReq := ss.Pty(); isPtyReq {
|
||||
w = ptyReq.Window
|
||||
@@ -1329,39 +1424,59 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
||||
ss: ss,
|
||||
start: now,
|
||||
}
|
||||
varRoot := ss.conn.srv.lb.TailscaleVarRoot()
|
||||
if varRoot == "" {
|
||||
return nil, errors.New("no var root for recording storage")
|
||||
}
|
||||
dir := filepath.Join(varRoot, "ssh-sessions")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// We want to use a background context for uploading and not ss.ctx.
|
||||
// ss.ctx is closed when the session closes, but we don't want to break the upload at that time.
|
||||
// Instead we want to wait for the session to close the writer when it finishes.
|
||||
ctx := context.Background()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("http://%s:%d/record", recorder.Addr(), recorder.Port()), pr)
|
||||
if err != nil {
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// We want to wait for the server to respond with 100 Continue to notifiy us
|
||||
// that it's ready to receive data. We do this to block the session from
|
||||
// starting until the server is ready to receive data.
|
||||
// It also allows the server to reject the request before we start sending
|
||||
// data.
|
||||
req.Header.Set("Expect", "100-continue")
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
ss.logf("starting asciinema recording to %s", recorder)
|
||||
hc := ss.conn.srv.sessionRecordingClient()
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
rec.Close()
|
||||
err := fmt.Errorf("recording: error sending recording: %w", err)
|
||||
ss.logf("%v", err)
|
||||
ss.cancelCtx(userVisibleError{
|
||||
msg: "recording: error sending recording",
|
||||
error: err,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer ss.cancelCtx(errors.New("recording: done"))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err := fmt.Errorf("recording: server responded with %s", resp.Status)
|
||||
ss.logf("%v", err)
|
||||
ss.cancelCtx(userVisibleError{
|
||||
msg: "recording server responded with: " + resp.Status,
|
||||
error: err,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
f, err := os.CreateTemp(dir, fmt.Sprintf("ssh-session-%v-*.cast", now.UnixNano()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec.out = f
|
||||
rec.out = pw
|
||||
|
||||
// {"version": 2, "width": 221, "height": 84, "timestamp": 1647146075, "env": {"SHELL": "/bin/bash", "TERM": "screen"}}
|
||||
type CastHeader struct {
|
||||
Version int `json:"version"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
j, err := json.Marshal(CastHeader{
|
||||
ch := CastHeader{
|
||||
Version: 2,
|
||||
Width: w.Width,
|
||||
Height: w.Height,
|
||||
Timestamp: now.Unix(),
|
||||
Command: strings.Join(ss.Command(), " "),
|
||||
Env: map[string]string{
|
||||
"TERM": term,
|
||||
// TODO(bradfitz): anything else important?
|
||||
@@ -1373,15 +1488,29 @@ func (ss *sshSession) startNewRecording() (_ *recording, err error) {
|
||||
// it. Then we can (1) make the cmd, (2) start the
|
||||
// recording, (3) start the process.
|
||||
},
|
||||
})
|
||||
SSHUser: ss.conn.info.sshUser,
|
||||
LocalUser: ss.conn.localUser.Username,
|
||||
SrcNode: strings.TrimSuffix(ss.conn.info.node.Name, "."),
|
||||
SrcNodeID: ss.conn.info.node.StableID,
|
||||
}
|
||||
if !ss.conn.info.node.IsTagged() {
|
||||
ch.SrcNodeUser = ss.conn.info.uprof.LoginName
|
||||
ch.SrcNodeUserID = ss.conn.info.node.User
|
||||
} else {
|
||||
ch.SrcNodeTags = ss.conn.info.node.Tags
|
||||
}
|
||||
j, err := json.Marshal(ch)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
ss.logf("starting asciinema recording to %s", f.Name())
|
||||
j = append(j, '\n')
|
||||
if _, err := f.Write(j); err != nil {
|
||||
f.Close()
|
||||
if _, err := pw.Write(j); err != nil {
|
||||
if errors.Is(err, io.ErrClosedPipe) && ss.ctx.Err() != nil {
|
||||
// If we got an io.ErrClosedPipe, it's likely because
|
||||
// the recording server closed the connection on us. Return
|
||||
// the original context error instead.
|
||||
return nil, context.Cause(ss.ctx)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
@@ -1393,7 +1522,7 @@ type recording struct {
|
||||
start time.Time
|
||||
|
||||
mu sync.Mutex // guards writes to, close of out
|
||||
out *os.File // nil if closed
|
||||
out io.WriteCloser
|
||||
}
|
||||
|
||||
func (r *recording) Close() error {
|
||||
@@ -1412,10 +1541,17 @@ func (r *recording) Close() error {
|
||||
// The dir should be "i" for input or "o" for output.
|
||||
//
|
||||
// If r is nil, it returns w unchanged.
|
||||
//
|
||||
// Currently (2023-03-21) we only record output, not input.
|
||||
func (r *recording) writer(dir string, w io.Writer) io.Writer {
|
||||
if r == nil {
|
||||
return w
|
||||
}
|
||||
if dir == "i" {
|
||||
// TODO: record input? Maybe not, since it might contain
|
||||
// passwords.
|
||||
return w
|
||||
}
|
||||
return &loggingWriter{r, dir, w}
|
||||
}
|
||||
|
||||
@@ -1489,3 +1625,19 @@ var (
|
||||
metricSFTP = clientmetric.NewCounter("ssh_sftp_requests")
|
||||
metricLocalPortForward = clientmetric.NewCounter("ssh_local_port_forward_requests")
|
||||
)
|
||||
|
||||
// userVisibleError is a wrapper around an error that implements
|
||||
// SSHTerminationError, so msg is written to their session.
|
||||
type userVisibleError struct {
|
||||
msg string
|
||||
error
|
||||
}
|
||||
|
||||
func (ue userVisibleError) SSHTerminationMessage() string { return ue.msg }
|
||||
|
||||
// SSHTerminationError is implemented by errors that terminate an SSH
|
||||
// session and should be written to user's sessions.
|
||||
type SSHTerminationError interface {
|
||||
error
|
||||
SSHTerminationMessage() string
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ package tailssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -236,6 +238,10 @@ var (
|
||||
testSignerOnce sync.Once
|
||||
)
|
||||
|
||||
func (ts *localState) Dialer() *tsdial.Dialer {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *localState) GetSSH_HostKeys() ([]gossh.Signer, error) {
|
||||
testSignerOnce.Do(func() {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
@@ -319,9 +325,213 @@ func newSSHRule(action *tailcfg.SSHAction) *tailcfg.SSHRule {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHRecordingCancelsSessionsOnUploadFailure(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
||||
}
|
||||
|
||||
var handler http.HandlerFunc
|
||||
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler(w, r)
|
||||
}))
|
||||
defer recordingServer.Close()
|
||||
|
||||
s := &server{
|
||||
logf: t.Logf,
|
||||
httpc: recordingServer.Client(),
|
||||
lb: &localState{
|
||||
sshEnabled: true,
|
||||
matchingRule: newSSHRule(
|
||||
&tailcfg.SSHAction{
|
||||
Accept: true,
|
||||
Recorders: []netip.AddrPort{
|
||||
netip.MustParseAddrPort(recordingServer.Listener.Addr().String()),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
const sshUser = "alice"
|
||||
cfg := &gossh.ClientConfig{
|
||||
User: sshUser,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
handler func(w http.ResponseWriter, r *http.Request)
|
||||
sshCommand string
|
||||
wantClientOutput string
|
||||
|
||||
clientOutputMustNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "upload-denied",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
},
|
||||
sshCommand: "echo hello",
|
||||
wantClientOutput: "recording: server responded with 403 Forbidden\r\n",
|
||||
|
||||
clientOutputMustNotContain: []string{"hello"},
|
||||
},
|
||||
{
|
||||
name: "upload-fails-after-starting",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body.Read(make([]byte, 1))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
},
|
||||
sshCommand: "echo hello && sleep 1 && echo world",
|
||||
wantClientOutput: "\r\n\r\nrecording server responded with: 500 Internal Server Error\r\n\r\n",
|
||||
|
||||
clientOutputMustNotContain: []string{"world"},
|
||||
},
|
||||
}
|
||||
|
||||
src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tstest.Replace(t, &handler, tt.handler)
|
||||
sc, dc := memnet.NewTCPConn(src, dst, 1024)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
return
|
||||
}
|
||||
client := gossh.NewClient(c, chans, reqs)
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
t.Logf("client established session")
|
||||
got, err := session.CombinedOutput(tt.sshCommand)
|
||||
if err != nil {
|
||||
t.Logf("client got: %q: %v", got, err)
|
||||
} else {
|
||||
t.Errorf("client did not get kicked out: %q", got)
|
||||
}
|
||||
gotStr := string(got)
|
||||
if !strings.HasSuffix(gotStr, tt.wantClientOutput) {
|
||||
t.Errorf("client got %q, want %q", got, tt.wantClientOutput)
|
||||
}
|
||||
for _, x := range tt.clientOutputMustNotContain {
|
||||
if strings.Contains(gotStr, x) {
|
||||
t.Errorf("client output must not contain %q", x)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := s.HandleSSHConn(dc); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSHRecordingNonInteractive tests that the SSH server records the SSH session
|
||||
// when the client is not interactive (i.e. no PTY).
|
||||
// It starts a local SSH server and a recording server. The recording server
|
||||
// records the SSH session and returns it to the test.
|
||||
// The test then verifies that the recording has a valid CastHeader, it does not
|
||||
// validate the contents of the recording.
|
||||
func TestSSHRecordingNonInteractive(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
||||
}
|
||||
var recording []byte
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
recordingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
var err error
|
||||
recording, err = ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer recordingServer.Close()
|
||||
|
||||
s := &server{
|
||||
logf: logger.Discard,
|
||||
httpc: recordingServer.Client(),
|
||||
lb: &localState{
|
||||
sshEnabled: true,
|
||||
matchingRule: newSSHRule(
|
||||
&tailcfg.SSHAction{
|
||||
Accept: true,
|
||||
Recorders: []netip.AddrPort{
|
||||
must.Get(netip.ParseAddrPort(recordingServer.Listener.Addr().String())),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
src, dst := must.Get(netip.ParseAddrPort("100.100.100.101:2231")), must.Get(netip.ParseAddrPort("100.100.100.102:22"))
|
||||
sc, dc := memnet.NewTCPConn(src, dst, 1024)
|
||||
|
||||
const sshUser = "alice"
|
||||
cfg := &gossh.ClientConfig{
|
||||
User: sshUser,
|
||||
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c, chans, reqs, err := gossh.NewClientConn(sc, sc.RemoteAddr().String(), cfg)
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
return
|
||||
}
|
||||
client := gossh.NewClient(c, chans, reqs)
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
return
|
||||
}
|
||||
defer session.Close()
|
||||
t.Logf("client established session")
|
||||
_, err = session.CombinedOutput("echo Ran echo!")
|
||||
if err != nil {
|
||||
t.Errorf("client: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := s.HandleSSHConn(dc); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
<-ctx.Done() // wait for recording to finish
|
||||
var ch CastHeader
|
||||
if err := json.NewDecoder(bytes.NewReader(recording)).Decode(&ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ch.SSHUser != sshUser {
|
||||
t.Errorf("SSHUser = %q; want %q", ch.SSHUser, sshUser)
|
||||
}
|
||||
if ch.Command != "echo Ran echo!" {
|
||||
t.Errorf("Command = %q; want %q", ch.Command, "echo Ran echo!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHAuthFlow(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Not running on Linux, skipping")
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skipf("skipping on %q; only runs on linux and darwin", runtime.GOOS)
|
||||
}
|
||||
acceptRule := newSSHRule(&tailcfg.SSHAction{
|
||||
Accept: true,
|
||||
@@ -539,7 +749,8 @@ func TestSSH(t *testing.T) {
|
||||
node: &tailcfg.Node{},
|
||||
uprof: tailcfg.UserProfile{},
|
||||
}
|
||||
sc.finalAction = &tailcfg.SSHAction{Accept: true}
|
||||
sc.action0 = &tailcfg.SSHAction{Accept: true}
|
||||
sc.finalAction = sc.action0
|
||||
|
||||
sc.Handler = func(s ssh.Session) {
|
||||
sc.newSSHSession(s).run()
|
||||
|
||||
@@ -217,3 +217,19 @@ func (m *Map[K, V]) Range(f func(key K, value V) bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitGroup is identical to [sync.WaitGroup],
|
||||
// but provides a Go method to start a goroutine.
|
||||
type WaitGroup struct{ sync.WaitGroup }
|
||||
|
||||
// Go calls the given function in a new goroutine.
|
||||
// It automatically increments the counter before execution and
|
||||
// automatically decrements the counter after execution.
|
||||
// It must not be called concurrently with Wait.
|
||||
func (wg *WaitGroup) Go(f func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package tailcfg
|
||||
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHPrincipal,ControlDialPlan --clonefunc
|
||||
//go:generate go run tailscale.com/cmd/viewer --type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan --clonefunc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -94,7 +94,8 @@ type CapabilityVersion int
|
||||
// - 55: 2023-01-23: start of c2n GET+POST /update handler
|
||||
// - 56: 2023-01-24: Client understands CapabilityDebugTSDNSResolution
|
||||
// - 57: 2023-01-25: Client understands CapabilityBindToInterfaceByRoute
|
||||
const CurrentCapabilityVersion CapabilityVersion = 57
|
||||
// - 58: 2023-03-10: Client retries lite map updates before restarting map poll.
|
||||
const CurrentCapabilityVersion CapabilityVersion = 58
|
||||
|
||||
type StableID string
|
||||
|
||||
@@ -182,7 +183,12 @@ func (emptyStructJSONSlice) UnmarshalJSON([]byte) error { return nil }
|
||||
type Node struct {
|
||||
ID NodeID
|
||||
StableID StableNodeID
|
||||
Name string // DNS
|
||||
|
||||
// Name is the FQDN of the node.
|
||||
// It is also the MagicDNS name for the node.
|
||||
// It has a trailing dot.
|
||||
// e.g. "host.tail-scale.ts.net."
|
||||
Name string
|
||||
|
||||
// User is the user who created the node. If ACL tags are in
|
||||
// use for the node then it doesn't reflect the ACL identity
|
||||
@@ -313,6 +319,11 @@ func (n *Node) DisplayNames(forOwner bool) (name, hostIfDifferent string) {
|
||||
return n.ComputedName, ""
|
||||
}
|
||||
|
||||
// IsTagged reports whether the node has any tags.
|
||||
func (n *Node) IsTagged() bool {
|
||||
return len(n.Tags) > 0
|
||||
}
|
||||
|
||||
// InitDisplayNames computes and populates n's display name
|
||||
// fields: n.ComputedName, n.computedHostIfDifferent, and
|
||||
// n.ComputedNameWithHost.
|
||||
@@ -1058,6 +1069,11 @@ type PortRange struct {
|
||||
Last uint16
|
||||
}
|
||||
|
||||
// Contains reports whether port is in pr.
|
||||
func (pr PortRange) Contains(port uint16) bool {
|
||||
return port >= pr.First && port <= pr.Last
|
||||
}
|
||||
|
||||
var PortRangeAny = PortRange{0, 65535}
|
||||
|
||||
// NetPortRange represents a range of ports that's allowed for one or more IPs.
|
||||
@@ -1806,7 +1822,8 @@ const (
|
||||
|
||||
// Funnel warning capabilities used for reporting errors to the user.
|
||||
|
||||
// CapabilityWarnFunnelNoInvite indicates an invite has not been accepted for the Funnel alpha.
|
||||
// CapabilityWarnFunnelNoInvite indicates whether Funnel is enabled for the tailnet.
|
||||
// NOTE: In transition from Alpha to Beta, this capability is being reused as the enablement.
|
||||
CapabilityWarnFunnelNoInvite = "https://tailscale.com/cap/warn-funnel-no-invite"
|
||||
|
||||
// CapabilityWarnFunnelNoHTTPS indicates HTTPS has not been enabled for the tailnet.
|
||||
@@ -1818,6 +1835,12 @@ const (
|
||||
// resolution for Tailscale-controlled domains (the control server, log
|
||||
// server, DERP servers, etc.)
|
||||
CapabilityDebugTSDNSResolution = "https://tailscale.com/cap/debug-ts-dns-resolution"
|
||||
|
||||
// CapabilityFunnelPorts specifies the ports that the Funnel is available on.
|
||||
// The ports are specified as a comma-separated list of port numbers or port
|
||||
// ranges (e.g. "80,443,8080-8090") in the ports query parameter.
|
||||
// e.g. https://tailscale.com/cap/funnel-ports?ports=80,443,8080-8090
|
||||
CapabilityFunnelPorts = "https://tailscale.com/cap/funnel-ports"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1999,9 +2022,9 @@ type SSHAction struct {
|
||||
// to use local port forwarding if requested.
|
||||
AllowLocalPortForwarding bool `json:"allowLocalPortForwarding,omitempty"`
|
||||
|
||||
// SessionHaulTargetNode, if non-empty, is the Stable ID of a peer to
|
||||
// stream this SSH session's logs to.
|
||||
SessionHaulTargetNode StableNodeID `json:"sessionHaulTargetNode,omitempty"`
|
||||
// Recorders defines the destinations of the SSH session recorders.
|
||||
// The recording will be uploaded to http://addr:port/record.
|
||||
Recorders []netip.AddrPort `json:"recorders"`
|
||||
}
|
||||
|
||||
// OverTLSPublicKeyResponse is the JSON response to /key?v=<n>
|
||||
|
||||
@@ -371,10 +371,7 @@ func (src *SSHRule) Clone() *SSHRule {
|
||||
dst.SSHUsers[k] = v
|
||||
}
|
||||
}
|
||||
if dst.Action != nil {
|
||||
dst.Action = new(SSHAction)
|
||||
*dst.Action = *src.Action
|
||||
}
|
||||
dst.Action = src.Action.Clone()
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -386,6 +383,30 @@ var _SSHRuleCloneNeedsRegeneration = SSHRule(struct {
|
||||
Action *SSHAction
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of SSHAction.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *SSHAction) Clone() *SSHAction {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := new(SSHAction)
|
||||
*dst = *src
|
||||
dst.Recorders = append(src.Recorders[:0:0], src.Recorders...)
|
||||
return dst
|
||||
}
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _SSHActionCloneNeedsRegeneration = SSHAction(struct {
|
||||
Message string
|
||||
Reject bool
|
||||
Accept bool
|
||||
SessionDuration time.Duration
|
||||
AllowAgentForwarding bool
|
||||
HoldAndDelegate string
|
||||
AllowLocalPortForwarding bool
|
||||
Recorders []netip.AddrPort
|
||||
}{})
|
||||
|
||||
// Clone makes a deep copy of SSHPrincipal.
|
||||
// The result aliases no memory with the original.
|
||||
func (src *SSHPrincipal) Clone() *SSHPrincipal {
|
||||
@@ -426,7 +447,7 @@ var _ControlDialPlanCloneNeedsRegeneration = ControlDialPlan(struct {
|
||||
|
||||
// Clone duplicates src into dst and reports whether it succeeded.
|
||||
// To succeed, <src, dst> must be of types <*T, *T> or <*T, **T>,
|
||||
// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHPrincipal,ControlDialPlan.
|
||||
// where T is one of User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan.
|
||||
func Clone(dst, src any) bool {
|
||||
switch src := src.(type) {
|
||||
case *User:
|
||||
@@ -528,6 +549,15 @@ func Clone(dst, src any) bool {
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
case *SSHAction:
|
||||
switch dst := dst.(type) {
|
||||
case *SSHAction:
|
||||
*dst = *src.Clone()
|
||||
return true
|
||||
case **SSHAction:
|
||||
*dst = src.Clone()
|
||||
return true
|
||||
}
|
||||
case *SSHPrincipal:
|
||||
switch dst := dst.(type) {
|
||||
case *SSHPrincipal:
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHPrincipal,ControlDialPlan
|
||||
//go:generate go run tailscale.com/cmd/cloner -clonefunc=true -type=User,Node,Hostinfo,NetInfo,Login,DNSConfig,RegisterResponse,DERPRegion,DERPMap,DERPNode,SSHRule,SSHAction,SSHPrincipal,ControlDialPlan
|
||||
|
||||
// View returns a readonly view of User.
|
||||
func (p *User) View() UserView {
|
||||
@@ -865,13 +865,7 @@ func (v SSHRuleView) Principals() views.SliceView[*SSHPrincipal, SSHPrincipalVie
|
||||
}
|
||||
|
||||
func (v SSHRuleView) SSHUsers() views.Map[string, string] { return views.MapOf(v.ж.SSHUsers) }
|
||||
func (v SSHRuleView) Action() *SSHAction {
|
||||
if v.ж.Action == nil {
|
||||
return nil
|
||||
}
|
||||
x := *v.ж.Action
|
||||
return &x
|
||||
}
|
||||
func (v SSHRuleView) Action() SSHActionView { return v.ж.Action.View() }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _SSHRuleViewNeedsRegeneration = SSHRule(struct {
|
||||
@@ -881,6 +875,72 @@ var _SSHRuleViewNeedsRegeneration = SSHRule(struct {
|
||||
Action *SSHAction
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of SSHAction.
|
||||
func (p *SSHAction) View() SSHActionView {
|
||||
return SSHActionView{ж: p}
|
||||
}
|
||||
|
||||
// SSHActionView provides a read-only view over SSHAction.
|
||||
//
|
||||
// Its methods should only be called if `Valid()` returns true.
|
||||
type SSHActionView struct {
|
||||
// ж is the underlying mutable value, named with a hard-to-type
|
||||
// character that looks pointy like a pointer.
|
||||
// It is named distinctively to make you think of how dangerous it is to escape
|
||||
// to callers. You must not let callers be able to mutate it.
|
||||
ж *SSHAction
|
||||
}
|
||||
|
||||
// Valid reports whether underlying value is non-nil.
|
||||
func (v SSHActionView) Valid() bool { return v.ж != nil }
|
||||
|
||||
// AsStruct returns a clone of the underlying value which aliases no memory with
|
||||
// the original.
|
||||
func (v SSHActionView) AsStruct() *SSHAction {
|
||||
if v.ж == nil {
|
||||
return nil
|
||||
}
|
||||
return v.ж.Clone()
|
||||
}
|
||||
|
||||
func (v SSHActionView) MarshalJSON() ([]byte, error) { return json.Marshal(v.ж) }
|
||||
|
||||
func (v *SSHActionView) UnmarshalJSON(b []byte) error {
|
||||
if v.ж != nil {
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var x SSHAction
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ж = &x
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v SSHActionView) Message() string { return v.ж.Message }
|
||||
func (v SSHActionView) Reject() bool { return v.ж.Reject }
|
||||
func (v SSHActionView) Accept() bool { return v.ж.Accept }
|
||||
func (v SSHActionView) SessionDuration() time.Duration { return v.ж.SessionDuration }
|
||||
func (v SSHActionView) AllowAgentForwarding() bool { return v.ж.AllowAgentForwarding }
|
||||
func (v SSHActionView) HoldAndDelegate() string { return v.ж.HoldAndDelegate }
|
||||
func (v SSHActionView) AllowLocalPortForwarding() bool { return v.ж.AllowLocalPortForwarding }
|
||||
func (v SSHActionView) Recorders() views.Slice[netip.AddrPort] { return views.SliceOf(v.ж.Recorders) }
|
||||
|
||||
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
|
||||
var _SSHActionViewNeedsRegeneration = SSHAction(struct {
|
||||
Message string
|
||||
Reject bool
|
||||
Accept bool
|
||||
SessionDuration time.Duration
|
||||
AllowAgentForwarding bool
|
||||
HoldAndDelegate string
|
||||
AllowLocalPortForwarding bool
|
||||
Recorders []netip.AddrPort
|
||||
}{})
|
||||
|
||||
// View returns a readonly view of SSHPrincipal.
|
||||
func (p *SSHPrincipal) View() SSHPrincipalView {
|
||||
return SSHPrincipalView{ж: p}
|
||||
|
||||
43
tsnet/example/tsnet-funnel/tsnet-funnel.go
Normal file
43
tsnet/example/tsnet-funnel/tsnet-funnel.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The tsnet-funnel server demonstrates how to use tsnet with Funnel.
|
||||
//
|
||||
// To use it, generate an auth key from the Tailscale admin panel and
|
||||
// run the demo with the key:
|
||||
//
|
||||
// TS_AUTHKEY=<yourkey> go run tsnet-funnel.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/tsnet"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
s := &tsnet.Server{
|
||||
Dir: "./funnel-demo-config",
|
||||
Logf: logger.Discard,
|
||||
Hostname: "fun",
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
ln, err := s.ListenFunnel("tcp", ":443")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
fmt.Printf("Listening on https://%v\n", s.CertDomains()[0])
|
||||
|
||||
err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "<html><body><h1>Hello, internet!</h1>")
|
||||
}))
|
||||
log.Fatal(err)
|
||||
}
|
||||
290
tsnet/tsnet.go
290
tsnet/tsnet.go
@@ -9,6 +9,7 @@ package tsnet
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -20,10 +21,12 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/control/controlclient"
|
||||
"tailscale.com/envknob"
|
||||
@@ -37,6 +40,7 @@ import (
|
||||
"tailscale.com/logpolicy"
|
||||
"tailscale.com/logtail"
|
||||
"tailscale.com/logtail/filch"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
"tailscale.com/net/memnet"
|
||||
"tailscale.com/net/proxymux"
|
||||
"tailscale.com/net/socks5"
|
||||
@@ -79,7 +83,7 @@ type Server struct {
|
||||
Logf logger.Logf
|
||||
|
||||
// Ephemeral, if true, specifies that the instance should register
|
||||
// as an Ephemeral node (https://tailscale.com/kb/1111/ephemeral-nodes/).
|
||||
// as an Ephemeral node (https://tailscale.com/s/ephemeral-nodes).
|
||||
Ephemeral bool
|
||||
|
||||
// AuthKey, if non-empty, is the auth key to create the node
|
||||
@@ -114,6 +118,7 @@ type Server struct {
|
||||
mu sync.Mutex
|
||||
listeners map[listenKey]*listener
|
||||
dialer *tsdial.Dialer
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Dial connects to the address on the tailnet.
|
||||
@@ -275,6 +280,14 @@ func (s *Server) Up(ctx context.Context) (*ipnstate.Status, error) {
|
||||
if len(status.TailscaleIPs) == 0 {
|
||||
return nil, errors.New("tsnet.Up: running, but no ip")
|
||||
}
|
||||
|
||||
// Clear the persisted serve config state to prevent stale configuration
|
||||
// from code changes. This is a temporary workaround until we have a better
|
||||
// way to handle this. (2023-03-11)
|
||||
if err := lc.SetServeConfig(ctx, new(ipn.ServeConfig)); err != nil {
|
||||
return nil, fmt.Errorf("tsnet.Up: %w", err)
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
// TODO: in the future, return an error on ipn.NeedsLogin
|
||||
@@ -291,6 +304,11 @@ func (s *Server) Up(ctx context.Context) (*ipnstate.Status, error) {
|
||||
//
|
||||
// It must not be called before or concurrently with Start.
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return fmt.Errorf("tsnet: %w", net.ErrClosed)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var wg sync.WaitGroup
|
||||
@@ -338,14 +356,12 @@ func (s *Server) Close() error {
|
||||
s.loopbackListener.Close()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, ln := range s.listeners {
|
||||
ln.Close()
|
||||
ln.closeLocked()
|
||||
}
|
||||
s.listeners = nil
|
||||
|
||||
wg.Wait()
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -356,11 +372,26 @@ func (s *Server) doInit() {
|
||||
}
|
||||
}
|
||||
|
||||
// CertDomains returns the list of domains for which the server can
|
||||
// provide TLS certificates. These are also the DNS names for the
|
||||
// Server.
|
||||
// If the server is not running, it returns nil.
|
||||
func (s *Server) CertDomains() []string {
|
||||
nm := s.lb.NetMap()
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
return slices.Clone(nm.DNS.CertDomains)
|
||||
}
|
||||
|
||||
// TailscaleIPs returns IPv4 and IPv6 addresses for this node. If the node
|
||||
// has not yet joined a tailnet or is otherwise unaware of its own IP addresses,
|
||||
// the returned ip4, ip6 will be !netip.IsValid().
|
||||
func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) {
|
||||
nm := s.lb.NetMap()
|
||||
if nm == nil {
|
||||
return
|
||||
}
|
||||
for _, addr := range nm.Addresses {
|
||||
ip := addr.Addr()
|
||||
if ip.Is6() {
|
||||
@@ -415,9 +446,9 @@ func (s *Server) start() (reterr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(s.rootPath, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(s.rootPath, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Stat(s.rootPath); err != nil {
|
||||
return err
|
||||
@@ -519,6 +550,7 @@ func (s *Server) start() (reterr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("NewLocalBackend: %v", err)
|
||||
}
|
||||
lb.SetTCPHandlerForFunnelFlow(s.getTCPHandlerForFunnelFlow)
|
||||
lb.SetVarRoot(s.rootPath)
|
||||
logf("tsnet starting with hostname %q, varRoot %q", s.hostname, s.rootPath)
|
||||
s.lb = lb
|
||||
@@ -592,6 +624,25 @@ func (s *Server) logf(format string, a ...interface{}) {
|
||||
log.Printf(format, a...)
|
||||
}
|
||||
|
||||
// ReplaceGlobalLoggers will replace any Tailscale-specific package-global
|
||||
// loggers with this Server's logger. It returns a function that, when called,
|
||||
// will undo any changes made.
|
||||
//
|
||||
// Note that calling this function from multiple Servers will result in the
|
||||
// last call taking all logs; logs are not duplicated.
|
||||
func (s *Server) ReplaceGlobalLoggers() (undo func()) {
|
||||
var undos []func()
|
||||
|
||||
oldDnsFallback := dnsfallback.SetLogger(s.logf)
|
||||
undos = append(undos, func() { dnsfallback.SetLogger(oldDnsFallback) })
|
||||
|
||||
return func() {
|
||||
for _, fn := range undos {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printAuthURLLoop loops once every few seconds while the server is still running and
|
||||
// is in NeedsLogin state, printing out the auth URL.
|
||||
func (s *Server) printAuthURLLoop() {
|
||||
@@ -644,7 +695,7 @@ func networkForFamily(netBase string, is6 bool) string {
|
||||
// - ("tcp", "", port)
|
||||
//
|
||||
// The netBase is "tcp" or "udp" (without any '4' or '6' suffix).
|
||||
func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *listener, ok bool) {
|
||||
func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort, funnel bool) (_ *listener, ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, a := range [2]netip.Addr{0: dst.Addr()} {
|
||||
@@ -652,7 +703,7 @@ func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *list
|
||||
networkForFamily(netBase, dst.Addr().Is6()),
|
||||
netBase,
|
||||
} {
|
||||
if ln, ok := s.listeners[listenKey{net, a, dst.Port()}]; ok {
|
||||
if ln, ok := s.listeners[listenKey{net, a, dst.Port(), funnel}]; ok {
|
||||
return ln, true
|
||||
}
|
||||
}
|
||||
@@ -660,8 +711,29 @@ func (s *Server) listenerForDstAddr(netBase string, dst netip.AddrPort) (_ *list
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *Server) getTCPHandlerForFunnelFlow(src netip.AddrPort, dstPort uint16) (handler func(net.Conn)) {
|
||||
ipv4, ipv6 := s.TailscaleIPs()
|
||||
var dst netip.AddrPort
|
||||
if src.Addr().Is4() {
|
||||
if !ipv4.IsValid() {
|
||||
return nil
|
||||
}
|
||||
dst = netip.AddrPortFrom(ipv4, dstPort)
|
||||
} else {
|
||||
if !ipv6.IsValid() {
|
||||
return nil
|
||||
}
|
||||
dst = netip.AddrPortFrom(ipv6, dstPort)
|
||||
}
|
||||
ln, ok := s.listenerForDstAddr("tcp", dst, true)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ln.handle
|
||||
}
|
||||
|
||||
func (s *Server) getTCPHandlerForFlow(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
|
||||
ln, ok := s.listenerForDstAddr("tcp", dst)
|
||||
ln, ok := s.listenerForDstAddr("tcp", dst, false)
|
||||
if !ok {
|
||||
return nil, true // don't handle, don't forward to localhost
|
||||
}
|
||||
@@ -669,7 +741,7 @@ func (s *Server) getTCPHandlerForFlow(src, dst netip.AddrPort) (handler func(net
|
||||
}
|
||||
|
||||
func (s *Server) getUDPHandlerForFlow(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) {
|
||||
ln, ok := s.listenerForDstAddr("udp", dst)
|
||||
ln, ok := s.listenerForDstAddr("udp", dst, false)
|
||||
if !ok {
|
||||
return nil, true // don't handle, don't forward to localhost
|
||||
}
|
||||
@@ -738,6 +810,140 @@ func (s *Server) APIClient() (*tailscale.Client, error) {
|
||||
// Listen announces only on the Tailscale network.
|
||||
// It will start the server if it has not been started yet.
|
||||
func (s *Server) Listen(network, addr string) (net.Listener, error) {
|
||||
return s.listen(network, addr, listenOnTailnet)
|
||||
}
|
||||
|
||||
// ListenTLS announces only on the Tailscale network.
|
||||
// It returns a TLS listener wrapping the tsnet listener.
|
||||
// It will start the server if it has not been started yet.
|
||||
func (s *Server) ListenTLS(network, addr string) (net.Listener, error) {
|
||||
if network != "tcp" {
|
||||
return nil, fmt.Errorf("ListenTLS(%q, %q): only tcp is supported", network, addr)
|
||||
}
|
||||
ctx := context.Background()
|
||||
st, err := s.Up(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(st.CertDomains) == 0 {
|
||||
return nil, errors.New("tsnet: you must enable HTTPS in the admin panel to proceed. See https://tailscale.com/s/https")
|
||||
}
|
||||
|
||||
lc, err := s.LocalClient() // do local client first before listening.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ln, err := s.listen(network, addr, listenOnTailnet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tls.NewListener(ln, &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// FunnelOption is an option passed to ListenFunnel to configure the listener.
|
||||
type FunnelOption interface {
|
||||
funnelOption()
|
||||
}
|
||||
|
||||
type funnelOnly int
|
||||
|
||||
func (funnelOnly) funnelOption() {}
|
||||
|
||||
// FunnelOnly configures the listener to only respond to connections from Tailscale Funnel.
|
||||
// The local tailnet will not be able to connect to the listener.
|
||||
func FunnelOnly() FunnelOption { return funnelOnly(1) }
|
||||
|
||||
// ListenFunnel announces on the public internet using Tailscale Funnel.
|
||||
//
|
||||
// It also by default listens on your local tailnet, so connections can
|
||||
// come from either inside or outside your network. To restrict connections
|
||||
// to be just from the internet, use the FunnelOnly option.
|
||||
//
|
||||
// Currently (2023-03-10), Funnel only supports TCP on ports 443, 8443, and 10000.
|
||||
// The supported host name is limited to that configured for the tsnet.Server.
|
||||
// As such, the standard way to create funnel is:
|
||||
//
|
||||
// s.ListenFunnel("tcp", ":443")
|
||||
//
|
||||
// and the only other supported addrs currently are ":8443" and ":10000".
|
||||
//
|
||||
// It will start the server if it has not been started yet.
|
||||
func (s *Server) ListenFunnel(network, addr string, opts ...FunnelOption) (net.Listener, error) {
|
||||
if network != "tcp" {
|
||||
return nil, fmt.Errorf("ListenFunnel(%q, %q): only tcp is supported", network, addr)
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if host != "" {
|
||||
return nil, fmt.Errorf("ListenFunnel(%q, %q): host must be empty", network, addr)
|
||||
}
|
||||
port, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := s.Up(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ipn.CheckFunnelAccess(uint16(port), st.Self.Capabilities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lc, err := s.LocalClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// May not have funnel enabled. Enable it.
|
||||
srvConfig, err := lc.GetServeConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if srvConfig == nil {
|
||||
srvConfig = &ipn.ServeConfig{}
|
||||
}
|
||||
domain := st.CertDomains[0]
|
||||
hp := ipn.HostPort(domain + ":" + portStr)
|
||||
if !srvConfig.AllowFunnel[hp] {
|
||||
mak.Set(&srvConfig.AllowFunnel, hp, true)
|
||||
srvConfig.AllowFunnel[hp] = true
|
||||
if err := lc.SetServeConfig(ctx, srvConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Start a funnel listener.
|
||||
lnOn := listenOnBoth
|
||||
for _, opt := range opts {
|
||||
if _, ok := opt.(funnelOnly); ok {
|
||||
lnOn = listenOnFunnel
|
||||
}
|
||||
}
|
||||
ln, err := s.listen(network, addr, lnOn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tls.NewListener(ln, &tls.Config{
|
||||
GetCertificate: lc.GetCertificate,
|
||||
}), nil
|
||||
}
|
||||
|
||||
type listenOn string
|
||||
|
||||
const (
|
||||
listenOnTailnet = listenOn("listen-on-tailnet")
|
||||
listenOnFunnel = listenOn("listen-on-funnel")
|
||||
listenOnBoth = listenOn("listen-on-both")
|
||||
)
|
||||
|
||||
func (s *Server) listen(network, addr string, lnOn listenOn) (net.Listener, error) {
|
||||
switch network {
|
||||
case "", "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6":
|
||||
default:
|
||||
@@ -772,20 +978,37 @@ func (s *Server) Listen(network, addr string) (net.Listener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := listenKey{network, bindHostOrZero, uint16(port)}
|
||||
var keys []listenKey
|
||||
switch lnOn {
|
||||
case listenOnTailnet:
|
||||
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
|
||||
case listenOnFunnel:
|
||||
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
|
||||
case listenOnBoth:
|
||||
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), false})
|
||||
keys = append(keys, listenKey{network, bindHostOrZero, uint16(port), true})
|
||||
}
|
||||
|
||||
ln := &listener{
|
||||
s: s,
|
||||
key: key,
|
||||
keys: keys,
|
||||
addr: addr,
|
||||
|
||||
conn: make(chan net.Conn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, ok := s.listeners[key]; ok {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("tsnet: listener already open for %s, %s", network, addr)
|
||||
for _, key := range keys {
|
||||
if _, ok := s.listeners[key]; ok {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("tsnet: listener already open for %s, %s", network, addr)
|
||||
}
|
||||
}
|
||||
if s.listeners == nil {
|
||||
s.listeners = make(map[listenKey]*listener)
|
||||
}
|
||||
for _, key := range keys {
|
||||
s.listeners[key] = ln
|
||||
}
|
||||
mak.Set(&s.listeners, key, ln)
|
||||
s.mu.Unlock()
|
||||
return ln, nil
|
||||
}
|
||||
@@ -794,13 +1017,15 @@ type listenKey struct {
|
||||
network string
|
||||
host netip.Addr // or zero value for unspecified
|
||||
port uint16
|
||||
funnel bool
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
s *Server
|
||||
key listenKey
|
||||
addr string
|
||||
conn chan net.Conn
|
||||
s *Server
|
||||
keys []listenKey
|
||||
addr string
|
||||
conn chan net.Conn
|
||||
closed bool // guarded by s.mu
|
||||
}
|
||||
|
||||
func (ln *listener) Accept() (net.Conn, error) {
|
||||
@@ -812,13 +1037,26 @@ func (ln *listener) Accept() (net.Conn, error) {
|
||||
}
|
||||
|
||||
func (ln *listener) Addr() net.Addr { return addr{ln} }
|
||||
|
||||
func (ln *listener) Close() error {
|
||||
ln.s.mu.Lock()
|
||||
defer ln.s.mu.Unlock()
|
||||
if v, ok := ln.s.listeners[ln.key]; ok && v == ln {
|
||||
delete(ln.s.listeners, ln.key)
|
||||
close(ln.conn)
|
||||
return ln.closeLocked()
|
||||
}
|
||||
|
||||
// closeLocked closes the listener.
|
||||
// It must be called with ln.s.mu held.
|
||||
func (ln *listener) closeLocked() error {
|
||||
if ln.closed {
|
||||
return fmt.Errorf("tsnet: %w", net.ErrClosed)
|
||||
}
|
||||
for _, key := range ln.keys {
|
||||
if v, ok := ln.s.listeners[key]; ok && v == ln {
|
||||
delete(ln.s.listeners, key)
|
||||
}
|
||||
}
|
||||
close(ln.conn)
|
||||
ln.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -839,5 +1077,5 @@ func (ln *listener) Server() *Server { return ln.s }
|
||||
|
||||
type addr struct{ ln *listener }
|
||||
|
||||
func (a addr) Network() string { return a.ln.key.network }
|
||||
func (a addr) Network() string { return a.ln.keys[0].network }
|
||||
func (a addr) String() string { return a.ln.addr }
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -344,3 +345,26 @@ func TestTailscaleIPs(t *testing.T) {
|
||||
sIp4, upIp4, sIp6, upIp6)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListenerCleanup is a regression test to verify that s.Close doesn't
|
||||
// deadlock if a listener is still open.
|
||||
func TestListenerCleanup(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
controlURL := startControl(t)
|
||||
s1, _ := startServer(t, ctx, controlURL, "s1")
|
||||
|
||||
ln, err := s1.Listen("tcp", ":8081")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s1.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ln.Close(); !errors.Is(err, net.ErrClosed) {
|
||||
t.Fatalf("second ln.Close error: %v, want net.ErrClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ func Every(interval time.Duration) Limit {
|
||||
}
|
||||
|
||||
// A Limiter controls how frequently events are allowed to happen.
|
||||
// It implements a "token bucket" of size b, initially full and refilled
|
||||
// at rate r tokens per second.
|
||||
// Informally, in any large enough time interval, the Limiter limits the
|
||||
// rate to r tokens per second, with a maximum burst size of b events.
|
||||
// See https://en.wikipedia.org/wiki/Token_bucket for more about token buckets.
|
||||
// It implements a [token bucket] of a particular size b,
|
||||
// initially full and refilled at rate r tokens per second.
|
||||
// Informally, in any large enough time interval,
|
||||
// the Limiter limits the rate to r tokens per second,
|
||||
// with a maximum burst size of b events.
|
||||
// Use NewLimiter to create non-zero Limiters.
|
||||
//
|
||||
// [token bucket]: https://en.wikipedia.org/wiki/Token_bucket
|
||||
type Limiter struct {
|
||||
limit Limit
|
||||
burst float64
|
||||
@@ -54,7 +56,7 @@ func NewLimiter(r Limit, b int) *Limiter {
|
||||
return &Limiter{limit: r, burst: float64(b)}
|
||||
}
|
||||
|
||||
// AllowN reports whether an event may happen now.
|
||||
// Allow reports whether an event may happen now.
|
||||
func (lim *Limiter) Allow() bool {
|
||||
return lim.allow(mono.Now())
|
||||
}
|
||||
|
||||
183
tstime/rate/value.go
Normal file
183
tstime/rate/value.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tailscale.com/tstime/mono"
|
||||
)
|
||||
|
||||
// Value measures the rate at which events occur,
|
||||
// exponentially weighted towards recent activity.
|
||||
// It is guaranteed to occupy O(1) memory, operate in O(1) runtime,
|
||||
// and is safe for concurrent use.
|
||||
// The zero value is safe for immediate use.
|
||||
//
|
||||
// The algorithm is based on and semantically equivalent to
|
||||
// [exponentially weighted moving averages (EWMAs)],
|
||||
// but modified to avoid assuming that event samples are gathered
|
||||
// at fixed and discrete time-step intervals.
|
||||
//
|
||||
// In EWMA literature, the average is typically tuned with a λ parameter
|
||||
// that determines how much weight to give to recent event samples.
|
||||
// A high λ value reacts quickly to new events favoring recent history,
|
||||
// while a low λ value reacts more slowly to new events.
|
||||
// The EWMA is computed as:
|
||||
//
|
||||
// zᵢ = λxᵢ + (1-λ)zᵢ₋₁
|
||||
//
|
||||
// where:
|
||||
// - λ is the weight parameter, where 0 ≤ λ ≤ 1
|
||||
// - xᵢ is the number of events that has since occurred
|
||||
// - zᵢ is the newly computed moving average
|
||||
// - zᵢ₋₁ is the previous moving average one time-step ago
|
||||
//
|
||||
// As mentioned, this implementation does not assume that the average
|
||||
// is updated periodically on a fixed time-step interval,
|
||||
// but allows the application to indicate that events occurred
|
||||
// at any point in time by simply calling Value.Add.
|
||||
// Thus, for every time Value.Add is called, it takes into consideration
|
||||
// the amount of time elapsed since the last call to Value.Add as
|
||||
// opposed to assuming that every call to Value.Add is evenly spaced
|
||||
// some fixed time-step interval apart.
|
||||
//
|
||||
// Since time is critical to this measurement, we tune the metric not
|
||||
// with the weight parameter λ (a unit-less constant between 0 and 1),
|
||||
// but rather as a half-life period t½. The half-life period is
|
||||
// mathematically equivalent but easier for humans to reason about.
|
||||
// The parameters λ and t½ and directly related in the following way:
|
||||
//
|
||||
// t½ = -(ln(2) · ΔT) / ln(1 - λ)
|
||||
//
|
||||
// λ = 1 - 2^-(ΔT / t½)
|
||||
//
|
||||
// where:
|
||||
// - t½ is the half-life commonly used with exponential decay
|
||||
// - λ is the unit-less weight parameter commonly used with EWMAs
|
||||
// - ΔT is the discrete time-step interval used with EWMAs
|
||||
//
|
||||
// The internal algorithm does not use the EWMA formula,
|
||||
// but is rather based on [half-life decay].
|
||||
// The formula for half-life decay is mathematically related
|
||||
// to the formula for computing the EWMA.
|
||||
// The calculation of an EWMA is a geometric progression [[1]] and
|
||||
// is essentially a discrete version of an exponential function [[2]],
|
||||
// for which half-life decay is one particular expression.
|
||||
// Given sufficiently small time-steps, the EWMA and half-life
|
||||
// algorithms provide equivalent results.
|
||||
//
|
||||
// The Value type does not take ΔT as a parameter since it relies
|
||||
// on a timer with nanosecond resolution. In a way, one could treat
|
||||
// this algorithm as operating on a ΔT of 1ns. Practically speaking,
|
||||
// the computation operates on non-discrete time intervals.
|
||||
//
|
||||
// [exponentially weighted moving averages (EWMAs)]: https://en.wikipedia.org/wiki/EWMA_chart
|
||||
// [half-life decay]: https://en.wikipedia.org/wiki/Half-life
|
||||
// [1]: https://en.wikipedia.org/wiki/Exponential_smoothing#%22Exponential%22_naming
|
||||
// [2]: https://en.wikipedia.org/wiki/Exponential_decay
|
||||
type Value struct {
|
||||
// HalfLife specifies how quickly the rate reacts to rate changes.
|
||||
//
|
||||
// Specifically, if there is currently a steady-state rate of
|
||||
// 0 events per second, and then immediately the rate jumped to
|
||||
// N events per second, then it will take HalfLife seconds until
|
||||
// the Value represents a rate of N/2 events per second and
|
||||
// 2*HalfLife seconds until the Value represents a rate of 3*N/4
|
||||
// events per second, and so forth. The rate represented by Value
|
||||
// will asymptotically approach N events per second over time.
|
||||
//
|
||||
// In order for Value to stably represent a steady-state rate,
|
||||
// the HalfLife should be larger than the average period between
|
||||
// calls to Value.Add.
|
||||
//
|
||||
// A zero or negative HalfLife is by default 1 second.
|
||||
HalfLife time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
updated mono.Time
|
||||
value float64 // adjusted count of events
|
||||
}
|
||||
|
||||
// halfLife returns the half-life period in seconds.
|
||||
func (r *Value) halfLife() float64 {
|
||||
if r.HalfLife <= 0 {
|
||||
return time.Second.Seconds()
|
||||
}
|
||||
return time.Duration(r.HalfLife).Seconds()
|
||||
}
|
||||
|
||||
// Add records that n number of events just occurred,
|
||||
// which must be a finite and non-negative number.
|
||||
func (r *Value) Add(n float64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.addNow(mono.Now(), n)
|
||||
}
|
||||
func (r *Value) addNow(now mono.Time, n float64) {
|
||||
if n < 0 || math.IsInf(n, 0) || math.IsNaN(n) {
|
||||
panic(fmt.Sprintf("invalid count %f; must be a finite, non-negative number", n))
|
||||
}
|
||||
r.value = r.valueNow(now) + n
|
||||
r.updated = now
|
||||
}
|
||||
|
||||
// valueNow computes the number of events after some elapsed time.
|
||||
// The total count of events decay exponentially so that
|
||||
// the computed rate is biased towards recent history.
|
||||
func (r *Value) valueNow(now mono.Time) float64 {
|
||||
// This uses the half-life formula:
|
||||
// N(t) = N₀ · 2^-(t / t½)
|
||||
// where:
|
||||
// N(t) is the amount remaining after time t,
|
||||
// N₀ is the initial quantity, and
|
||||
// t½ is the half-life of the decaying quantity.
|
||||
//
|
||||
// See https://en.wikipedia.org/wiki/Half-life
|
||||
age := now.Sub(r.updated).Seconds()
|
||||
return r.value * math.Exp2(-age/r.halfLife())
|
||||
}
|
||||
|
||||
// Rate computes the rate as events per second.
|
||||
func (r *Value) Rate() float64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.rateNow(mono.Now())
|
||||
}
|
||||
func (r *Value) rateNow(now mono.Time) float64 {
|
||||
// The stored value carries the units "events"
|
||||
// while we want to compute "events / second".
|
||||
//
|
||||
// In the trivial case where the events never decay,
|
||||
// the average rate can be computed by dividing the total events
|
||||
// by the total elapsed time since the start of the Value.
|
||||
// This works because the weight distribution is uniform such that
|
||||
// the weight of an event in the distant past is equal to
|
||||
// the weight of a recent event. This is not the case with
|
||||
// exponentially decaying weights, which complicates computation.
|
||||
//
|
||||
// Since our events are decaying, we can divide the number of events
|
||||
// by the total possible accumulated value, which we determine
|
||||
// by integrating the half-life formula from t=0 until t=∞,
|
||||
// assuming that N₀ is 1:
|
||||
// ∫ N(t) dt = t½ / ln(2)
|
||||
//
|
||||
// Recall that the integral of a curve is the area under a curve,
|
||||
// which carries the units of the X-axis multiplied by the Y-axis.
|
||||
// In our case this would be the units "events · seconds".
|
||||
// By normalizing N₀ to 1, the Y-axis becomes a unit-less quantity,
|
||||
// resulting in a integral unit of just "seconds".
|
||||
// Dividing the events by the integral quantity correctly produces
|
||||
// the units of "events / second".
|
||||
return r.valueNow(now) / r.normalizedIntegral()
|
||||
}
|
||||
|
||||
// normalizedIntegral computes the quantity t½ / ln(2).
|
||||
// It carries the units of "seconds".
|
||||
func (r *Value) normalizedIntegral() float64 {
|
||||
return r.halfLife() / math.Ln2
|
||||
}
|
||||
236
tstime/rate/value_test.go
Normal file
236
tstime/rate/value_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package rate
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qt "github.com/frankban/quicktest"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/tstime/mono"
|
||||
)
|
||||
|
||||
const (
|
||||
min = mono.Time(time.Minute)
|
||||
sec = mono.Time(time.Second)
|
||||
msec = mono.Time(time.Millisecond)
|
||||
usec = mono.Time(time.Microsecond)
|
||||
nsec = mono.Time(time.Nanosecond)
|
||||
|
||||
val = 1.0e6
|
||||
)
|
||||
|
||||
var longNumericalStabilityTest = flag.Bool("long-numerical-stability-test", false, "")
|
||||
|
||||
func TestValue(t *testing.T) {
|
||||
// When performing many small calculations, the accuracy of the
|
||||
// result can drift due to accumulated errors in the calculation.
|
||||
// Verify that the result is correct even with many small updates.
|
||||
// See https://en.wikipedia.org/wiki/Numerical_stability.
|
||||
t.Run("NumericalStability", func(t *testing.T) {
|
||||
step := usec
|
||||
if *longNumericalStabilityTest {
|
||||
step = nsec
|
||||
}
|
||||
numStep := int(sec / step)
|
||||
|
||||
c := qt.New(t)
|
||||
var v Value
|
||||
var now mono.Time
|
||||
for i := 0; i < numStep; i++ {
|
||||
v.addNow(now, float64(step))
|
||||
now += step
|
||||
}
|
||||
c.Assert(v.rateNow(now), qt.CmpEquals(cmpopts.EquateApprox(1e-6, 0)), 1e9/2)
|
||||
})
|
||||
|
||||
halfLives := []struct {
|
||||
name string
|
||||
period time.Duration
|
||||
}{
|
||||
{"½s", time.Second / 2},
|
||||
{"1s", time.Second},
|
||||
{"2s", 2 * time.Second},
|
||||
}
|
||||
for _, halfLife := range halfLives {
|
||||
t.Run(halfLife.name+"/SpikeDecay", func(t *testing.T) {
|
||||
testValueSpikeDecay(t, halfLife.period, false)
|
||||
})
|
||||
t.Run(halfLife.name+"/SpikeDecayAddZero", func(t *testing.T) {
|
||||
testValueSpikeDecay(t, halfLife.period, true)
|
||||
})
|
||||
t.Run(halfLife.name+"/HighThenLow", func(t *testing.T) {
|
||||
testValueHighThenLow(t, halfLife.period)
|
||||
})
|
||||
t.Run(halfLife.name+"/LowFrequency", func(t *testing.T) {
|
||||
testLowFrequency(t, halfLife.period)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testValueSpikeDecay starts with a target rate and ensure that it
|
||||
// exponentially decays according to the half-life formula.
|
||||
func testValueSpikeDecay(t *testing.T, halfLife time.Duration, addZero bool) {
|
||||
c := qt.New(t)
|
||||
v := Value{HalfLife: halfLife}
|
||||
v.addNow(0, val*v.normalizedIntegral())
|
||||
|
||||
var now mono.Time
|
||||
var prevRate float64
|
||||
step := 100 * msec
|
||||
wantHalfRate := float64(val)
|
||||
for now < 10*sec {
|
||||
// Adding zero for every time-step will repeatedly trigger the
|
||||
// computation to decay the value, which may cause the result
|
||||
// to become more numerically unstable.
|
||||
if addZero {
|
||||
v.addNow(now, 0)
|
||||
}
|
||||
currRate := v.rateNow(now)
|
||||
t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate)
|
||||
|
||||
// At every multiple of a half-life period,
|
||||
// the current rate should be half the value of what
|
||||
// it was at the last half-life period.
|
||||
if time.Duration(now)%halfLife == 0 {
|
||||
c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(1e-12, 0)), wantHalfRate)
|
||||
wantHalfRate = currRate / 2
|
||||
}
|
||||
|
||||
// Without any newly added events,
|
||||
// the rate should be decaying over time.
|
||||
if now > 0 && prevRate < currRate {
|
||||
t.Errorf("%v: rate is not decaying: %0.1f < %0.1f", time.Duration(now), prevRate, currRate)
|
||||
}
|
||||
if currRate < 0 {
|
||||
t.Errorf("%v: rate too low: %0.1f < %0.1f", time.Duration(now), currRate, 0.0)
|
||||
}
|
||||
|
||||
prevRate = currRate
|
||||
now += step
|
||||
}
|
||||
}
|
||||
|
||||
// testValueHighThenLow targets a steady-state rate that is high,
|
||||
// then switches to a target steady-state rate that is low.
|
||||
func testValueHighThenLow(t *testing.T, halfLife time.Duration) {
|
||||
c := qt.New(t)
|
||||
v := Value{HalfLife: halfLife}
|
||||
|
||||
var now mono.Time
|
||||
var prevRate float64
|
||||
var wantRate float64
|
||||
const step = 10 * msec
|
||||
const stepsPerSecond = int(sec / step)
|
||||
|
||||
// Target a higher steady-state rate.
|
||||
wantRate = 2 * val
|
||||
wantHalfRate := float64(0.0)
|
||||
eventsPerStep := wantRate / float64(stepsPerSecond)
|
||||
for now < 10*sec {
|
||||
currRate := v.rateNow(now)
|
||||
v.addNow(now, eventsPerStep)
|
||||
t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate)
|
||||
|
||||
// At every multiple of a half-life period,
|
||||
// the current rate should be half-way more towards
|
||||
// the target rate relative to before.
|
||||
if time.Duration(now)%halfLife == 0 {
|
||||
c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(0.1, 0)), wantHalfRate)
|
||||
wantHalfRate += (wantRate - currRate) / 2
|
||||
}
|
||||
|
||||
// Rate should approach wantRate from below,
|
||||
// but never exceed it.
|
||||
if now > 0 && prevRate > currRate {
|
||||
t.Errorf("%v: rate is not growing: %0.1f > %0.1f", time.Duration(now), prevRate, currRate)
|
||||
}
|
||||
if currRate > 1.01*wantRate {
|
||||
t.Errorf("%v: rate too high: %0.1f > %0.1f", time.Duration(now), currRate, wantRate)
|
||||
}
|
||||
|
||||
prevRate = currRate
|
||||
now += step
|
||||
}
|
||||
c.Assert(prevRate, qt.CmpEquals(cmpopts.EquateApprox(0.05, 0)), wantRate)
|
||||
|
||||
// Target a lower steady-state rate.
|
||||
wantRate = val / 3
|
||||
wantHalfRate = prevRate
|
||||
eventsPerStep = wantRate / float64(stepsPerSecond)
|
||||
for now < 20*sec {
|
||||
currRate := v.rateNow(now)
|
||||
v.addNow(now, eventsPerStep)
|
||||
t.Logf("%0.1fs:\t%0.3f", time.Duration(now).Seconds(), currRate)
|
||||
|
||||
// At every multiple of a half-life period,
|
||||
// the current rate should be half-way more towards
|
||||
// the target rate relative to before.
|
||||
if time.Duration(now)%halfLife == 0 {
|
||||
c.Assert(currRate, qt.CmpEquals(cmpopts.EquateApprox(0.1, 0)), wantHalfRate)
|
||||
wantHalfRate += (wantRate - currRate) / 2
|
||||
}
|
||||
|
||||
// Rate should approach wantRate from above,
|
||||
// but never exceed it.
|
||||
if now > 10*sec && prevRate < currRate {
|
||||
t.Errorf("%v: rate is not decaying: %0.1f < %0.1f", time.Duration(now), prevRate, currRate)
|
||||
}
|
||||
if currRate < 0.99*wantRate {
|
||||
t.Errorf("%v: rate too low: %0.1f < %0.1f", time.Duration(now), currRate, wantRate)
|
||||
}
|
||||
|
||||
prevRate = currRate
|
||||
now += step
|
||||
}
|
||||
c.Assert(prevRate, qt.CmpEquals(cmpopts.EquateApprox(0.15, 0)), wantRate)
|
||||
}
|
||||
|
||||
// testLowFrequency fires an event at a frequency much slower than
|
||||
// the specified half-life period. While the average rate over time
|
||||
// should be accurate, the standard deviation gets worse.
|
||||
func testLowFrequency(t *testing.T, halfLife time.Duration) {
|
||||
v := Value{HalfLife: halfLife}
|
||||
|
||||
var now mono.Time
|
||||
var rates []float64
|
||||
for now < 20*min {
|
||||
if now%(10*sec) == 0 {
|
||||
v.addNow(now, 1) // 1 event every 10 seconds
|
||||
}
|
||||
now += 50 * msec
|
||||
rates = append(rates, v.rateNow(now))
|
||||
now += 50 * msec
|
||||
}
|
||||
|
||||
mean, stddev := stats(rates)
|
||||
c := qt.New(t)
|
||||
c.Assert(mean, qt.CmpEquals(cmpopts.EquateApprox(0.001, 0)), 0.1)
|
||||
t.Logf("mean:%v stddev:%v", mean, stddev)
|
||||
}
|
||||
|
||||
func stats(fs []float64) (mean, stddev float64) {
|
||||
for _, rate := range fs {
|
||||
mean += rate
|
||||
}
|
||||
mean /= float64(len(fs))
|
||||
for _, rate := range fs {
|
||||
stddev += (rate - mean) * (rate - mean)
|
||||
}
|
||||
stddev = math.Sqrt(stddev / float64(len(fs)))
|
||||
return mean, stddev
|
||||
}
|
||||
|
||||
// BenchmarkValue benchmarks the cost of Value.Add,
|
||||
// which is called often and makes extensive use of floating-point math.
|
||||
func BenchmarkValue(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
v := Value{HalfLife: time.Second}
|
||||
for i := 0; i < b.N; i++ {
|
||||
v.Add(1)
|
||||
}
|
||||
}
|
||||
@@ -150,14 +150,14 @@ func InfoFrom(dir string) (VersionInfo, error) {
|
||||
}
|
||||
|
||||
// Note, this mechanism doesn't correctly support go.mod replacements,
|
||||
// or go workdirs. We only parse out the commit hash from go.mod's
|
||||
// or go workdirs. We only parse out the commit ref from go.mod's
|
||||
// "require" line, nothing else.
|
||||
tailscaleHash, err := tailscaleModuleHash(modBs)
|
||||
tailscaleRef, err := tailscaleModuleRef(modBs)
|
||||
if err != nil {
|
||||
return VersionInfo{}, err
|
||||
}
|
||||
|
||||
v, err := infoFromCache(tailscaleHash, runner)
|
||||
v, err := infoFromCache(tailscaleRef, runner)
|
||||
if err != nil {
|
||||
return VersionInfo{}, err
|
||||
}
|
||||
@@ -171,9 +171,10 @@ func InfoFrom(dir string) (VersionInfo, error) {
|
||||
return mkOutput(v)
|
||||
}
|
||||
|
||||
// tailscaleModuleHash returns the git hash of the 'require tailscale.com' line
|
||||
// in the given go.mod bytes.
|
||||
func tailscaleModuleHash(modBs []byte) (string, error) {
|
||||
// tailscaleModuleRef returns the git ref of the 'require tailscale.com' line
|
||||
// in the given go.mod bytes. The ref is either a short commit hash, or a git
|
||||
// tag.
|
||||
func tailscaleModuleRef(modBs []byte) (string, error) {
|
||||
mod, err := modfile.Parse("go.mod", modBs, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -187,7 +188,8 @@ func tailscaleModuleHash(modBs []byte) (string, error) {
|
||||
if i := strings.LastIndexByte(req.Mod.Version, '-'); i != -1 {
|
||||
return req.Mod.Version[i+1:], nil
|
||||
}
|
||||
return "", fmt.Errorf("couldn't parse git hash from tailscale.com version %q", req.Mod.Version)
|
||||
// If there are no dashes, the version is a tag.
|
||||
return req.Mod.Version, nil
|
||||
}
|
||||
return "", fmt.Errorf("no require tailscale.com line in go.mod")
|
||||
}
|
||||
@@ -310,7 +312,7 @@ type verInfo struct {
|
||||
// sentinel patch number.
|
||||
const unknownPatchVersion = 9999999
|
||||
|
||||
func infoFromCache(shortHash string, runner dirRunner) (verInfo, error) {
|
||||
func infoFromCache(ref string, runner dirRunner) (verInfo, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return verInfo{}, fmt.Errorf("Getting user cache dir: %w", err)
|
||||
@@ -324,16 +326,16 @@ func infoFromCache(shortHash string, runner dirRunner) (verInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if !r.ok("git", "cat-file", "-e", shortHash) {
|
||||
if !r.ok("git", "cat-file", "-e", ref) {
|
||||
if !r.ok("git", "fetch", "origin") {
|
||||
return verInfo{}, fmt.Errorf("updating OSS repo failed")
|
||||
}
|
||||
}
|
||||
hash, err := r.output("git", "rev-parse", shortHash)
|
||||
hash, err := r.output("git", "rev-parse", ref)
|
||||
if err != nil {
|
||||
return verInfo{}, err
|
||||
}
|
||||
date, err := r.output("git", "log", "-n1", "--format=%ct", shortHash)
|
||||
date, err := r.output("git", "log", "-n1", "--format=%ct", ref)
|
||||
if err != nil {
|
||||
return verInfo{}, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user