mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-31 13:05:22 +00:00 
			
		
		
		
	ssh/tailssh: remove unused public key support
When we first made Tailscale SSH, we assumed people would want public key support soon after. Turns out that hasn't been the case; people love the Tailscale identity authentication and check mode. In light of CVE-2024-45337, just remove all our public key code to not distract people, and to make the code smaller. We can always get it back from git if needed. Updates tailscale/corp#25131 Updates golang/go#70779 Co-authored-by: Percy Wegmann <percy@tailscale.com> Change-Id: I87a6e79c2215158766a81942227a18b247333c22 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
		 Brad Fitzpatrick
					Brad Fitzpatrick
				
			
				
					committed by
					
						 Brad Fitzpatrick
						Brad Fitzpatrick
					
				
			
			
				
	
			
			
			 Brad Fitzpatrick
						Brad Fitzpatrick
					
				
			
						parent
						
							716cb37256
						
					
				
				
					commit
					73128e2523
				
			
							
								
								
									
										1
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Makefile
									
									
									
									
									
								
							| @@ -116,7 +116,6 @@ sshintegrationtest: ## Run the SSH integration tests in various Docker container | |||||||
| 	GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \ | 	GOOS=linux GOARCH=amd64 ./tool/go build -o ssh/tailssh/testcontainers/tailscaled ./cmd/tailscaled && \ | ||||||
| 	echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \ | 	echo "Testing on ubuntu:focal" && docker build --build-arg="BASE=ubuntu:focal" -t ssh-ubuntu-focal ssh/tailssh/testcontainers && \ | ||||||
| 	echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \ | 	echo "Testing on ubuntu:jammy" && docker build --build-arg="BASE=ubuntu:jammy" -t ssh-ubuntu-jammy ssh/tailssh/testcontainers && \ | ||||||
| 	echo "Testing on ubuntu:mantic" && docker build --build-arg="BASE=ubuntu:mantic" -t ssh-ubuntu-mantic ssh/tailssh/testcontainers && \ |  | ||||||
| 	echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \ | 	echo "Testing on ubuntu:noble" && docker build --build-arg="BASE=ubuntu:noble" -t ssh-ubuntu-noble ssh/tailssh/testcontainers && \ | ||||||
| 	echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers | 	echo "Testing on alpine:latest" && docker build --build-arg="BASE=alpine:latest" -t ssh-alpine-latest ssh/tailssh/testcontainers | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ import ( | |||||||
| 	"bytes" | 	"bytes" | ||||||
| 	"context" | 	"context" | ||||||
| 	"crypto/rand" | 	"crypto/rand" | ||||||
| 	"encoding/base64" |  | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| @@ -45,7 +44,6 @@ import ( | |||||||
| 	"tailscale.com/util/clientmetric" | 	"tailscale.com/util/clientmetric" | ||||||
| 	"tailscale.com/util/httpm" | 	"tailscale.com/util/httpm" | ||||||
| 	"tailscale.com/util/mak" | 	"tailscale.com/util/mak" | ||||||
| 	"tailscale.com/util/slicesx" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
| @@ -80,16 +78,14 @@ type server struct { | |||||||
| 	logf           logger.Logf | 	logf           logger.Logf | ||||||
| 	tailscaledPath string | 	tailscaledPath string | ||||||
| 
 | 
 | ||||||
| 	pubKeyHTTPClient *http.Client     // or nil for http.DefaultClient | 	timeNow func() time.Time // or nil for time.Now | ||||||
| 	timeNow          func() time.Time // or nil for time.Now |  | ||||||
| 
 | 
 | ||||||
| 	sessionWaitGroup sync.WaitGroup | 	sessionWaitGroup sync.WaitGroup | ||||||
| 
 | 
 | ||||||
| 	// mu protects the following | 	// mu protects the following | ||||||
| 	mu                   sync.Mutex | 	mu             sync.Mutex | ||||||
| 	activeConns          map[*conn]bool              // set; value is always true | 	activeConns    map[*conn]bool // set; value is always true | ||||||
| 	fetchPublicKeysCache map[string]pubKeyCacheEntry // by https URL | 	shutdownCalled bool | ||||||
| 	shutdownCalled       bool |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (srv *server) now() time.Time { | func (srv *server) now() time.Time { | ||||||
| @@ -204,7 +200,6 @@ func (srv *server) OnPolicyChange() { | |||||||
| // | // | ||||||
| // Do the user auth | // Do the user auth | ||||||
| //   - NoClientAuthHandler | //   - NoClientAuthHandler | ||||||
| //   - PublicKeyHandler (only if NoClientAuthHandler returns errPubKeyRequired) |  | ||||||
| // | // | ||||||
| // Once auth is done, the conn can be multiplexed with multiple sessions and | // Once auth is done, the conn can be multiplexed with multiple sessions and | ||||||
| // channels concurrently. At which point any of the following can be called | // channels concurrently. At which point any of the following can be called | ||||||
| @@ -234,10 +229,9 @@ type conn struct { | |||||||
| 	finalAction    *tailcfg.SSHAction // set by doPolicyAuth or resolveNextAction | 	finalAction    *tailcfg.SSHAction // set by doPolicyAuth or resolveNextAction | ||||||
| 	finalActionErr error              // set by doPolicyAuth or resolveNextAction | 	finalActionErr error              // set by doPolicyAuth or resolveNextAction | ||||||
| 
 | 
 | ||||||
| 	info         *sshConnInfo    // set by setInfo | 	info         *sshConnInfo // set by setInfo | ||||||
| 	localUser    *userMeta       // set by doPolicyAuth | 	localUser    *userMeta    // set by doPolicyAuth | ||||||
| 	userGroupIDs []string        // set by doPolicyAuth | 	userGroupIDs []string     // set by doPolicyAuth | ||||||
| 	pubKey       gossh.PublicKey // set by doPolicyAuth |  | ||||||
| 	acceptEnv    []string | 	acceptEnv    []string | ||||||
| 
 | 
 | ||||||
| 	// mu protects the following fields. | 	// mu protects the following fields. | ||||||
| @@ -268,9 +262,6 @@ func (c *conn) isAuthorized(ctx ssh.Context) error { | |||||||
| 	action := c.currentAction | 	action := c.currentAction | ||||||
| 	for { | 	for { | ||||||
| 		if action.Accept { | 		if action.Accept { | ||||||
| 			if c.pubKey != nil { |  | ||||||
| 				metricPublicKeyAccepts.Add(1) |  | ||||||
| 			} |  | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
| 		if action.Reject || action.HoldAndDelegate == "" { | 		if action.Reject || action.HoldAndDelegate == "" { | ||||||
| @@ -293,10 +284,6 @@ func (c *conn) isAuthorized(ctx ssh.Context) error { | |||||||
| // policy. | // policy. | ||||||
| var errDenied = errors.New("ssh: access denied") | var errDenied = errors.New("ssh: access denied") | ||||||
| 
 | 
 | ||||||
| // errPubKeyRequired is returned by NoClientAuthCallback to make the client |  | ||||||
| // resort to public-key auth; not user visible. |  | ||||||
| var errPubKeyRequired = errors.New("ssh publickey required") |  | ||||||
| 
 |  | ||||||
| // NoClientAuthCallback implements gossh.NoClientAuthCallback and is called by | // NoClientAuthCallback implements gossh.NoClientAuthCallback and is called by | ||||||
| // the ssh.Server when the client first connects with the "none" | // the ssh.Server when the client first connects with the "none" | ||||||
| // authentication method. | // authentication method. | ||||||
| @@ -305,13 +292,12 @@ var errPubKeyRequired = errors.New("ssh publickey required") | |||||||
| // starting it afresh). It returns an error if the policy evaluation fails, or | // starting it afresh). It returns an error if the policy evaluation fails, or | ||||||
| // if the decision is "reject" | // if the decision is "reject" | ||||||
| // | // | ||||||
| // It either returns nil (accept) or errPubKeyRequired or errDenied | // It either returns nil (accept) or errDenied (reject). The errors may be wrapped. | ||||||
| // (reject). The errors may be wrapped. |  | ||||||
| func (c *conn) NoClientAuthCallback(ctx ssh.Context) error { | func (c *conn) NoClientAuthCallback(ctx ssh.Context) error { | ||||||
| 	if c.insecureSkipTailscaleAuth { | 	if c.insecureSkipTailscaleAuth { | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| 	if err := c.doPolicyAuth(ctx, nil /* no pub key */); err != nil { | 	if err := c.doPolicyAuth(ctx); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	if err := c.isAuthorized(ctx); err != nil { | 	if err := c.isAuthorized(ctx); err != nil { | ||||||
| @@ -332,8 +318,6 @@ func (c *conn) nextAuthMethodCallback(cm gossh.ConnMetadata, prevErrors []error) | |||||||
| 	switch { | 	switch { | ||||||
| 	case c.anyPasswordIsOkay: | 	case c.anyPasswordIsOkay: | ||||||
| 		nextMethod = append(nextMethod, "password") | 		nextMethod = append(nextMethod, "password") | ||||||
| 	case slicesx.LastEqual(prevErrors, errPubKeyRequired): |  | ||||||
| 		nextMethod = append(nextMethod, "publickey") |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// The fake "tailscale" method is always appended to next so OpenSSH renders | 	// The fake "tailscale" method is always appended to next so OpenSSH renders | ||||||
| @@ -353,41 +337,20 @@ func (c *conn) fakePasswordHandler(ctx ssh.Context, password string) bool { | |||||||
| 	return c.anyPasswordIsOkay | 	return c.anyPasswordIsOkay | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // PublicKeyHandler implements ssh.PublicKeyHandler is called by the | // doPolicyAuth verifies that conn can proceed. | ||||||
| // ssh.Server when the client presents a public key. | // It returns nil if the matching policy action is Accept or | ||||||
| func (c *conn) PublicKeyHandler(ctx ssh.Context, pubKey ssh.PublicKey) error { | // HoldAndDelegate. Otherwise, it returns errDenied. | ||||||
| 	if err := c.doPolicyAuth(ctx, pubKey); err != nil { | func (c *conn) doPolicyAuth(ctx ssh.Context) error { | ||||||
| 		// TODO(maisem/bradfitz): surface the error here. |  | ||||||
| 		c.logf("rejecting SSH public key %s: %v", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey)), err) |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	if err := c.isAuthorized(ctx); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	c.logf("accepting SSH public key %s", bytes.TrimSpace(gossh.MarshalAuthorizedKey(pubKey))) |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // doPolicyAuth verifies that conn can proceed with the specified (optional) |  | ||||||
| // pubKey. It returns nil if the matching policy action is Accept or |  | ||||||
| // HoldAndDelegate. If pubKey is nil, there was no policy match but there is a |  | ||||||
| // policy that might match a public key it returns errPubKeyRequired. Otherwise, |  | ||||||
| // it returns errDenied. |  | ||||||
| func (c *conn) doPolicyAuth(ctx ssh.Context, pubKey ssh.PublicKey) error { |  | ||||||
| 	if err := c.setInfo(ctx); err != nil { | 	if err := c.setInfo(ctx); err != nil { | ||||||
| 		c.logf("failed to get conninfo: %v", err) | 		c.logf("failed to get conninfo: %v", err) | ||||||
| 		return errDenied | 		return errDenied | ||||||
| 	} | 	} | ||||||
| 	a, localUser, acceptEnv, err := c.evaluatePolicy(pubKey) | 	a, localUser, acceptEnv, err := c.evaluatePolicy() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if pubKey == nil && c.havePubKeyPolicy() { |  | ||||||
| 			return errPubKeyRequired |  | ||||||
| 		} |  | ||||||
| 		return fmt.Errorf("%w: %v", errDenied, err) | 		return fmt.Errorf("%w: %v", errDenied, err) | ||||||
| 	} | 	} | ||||||
| 	c.action0 = a | 	c.action0 = a | ||||||
| 	c.currentAction = a | 	c.currentAction = a | ||||||
| 	c.pubKey = pubKey |  | ||||||
| 	c.acceptEnv = acceptEnv | 	c.acceptEnv = acceptEnv | ||||||
| 	if a.Message != "" { | 	if a.Message != "" { | ||||||
| 		if err := ctx.SendAuthBanner(a.Message); err != nil { | 		if err := ctx.SendAuthBanner(a.Message); err != nil { | ||||||
| @@ -448,7 +411,6 @@ func (srv *server) newConn() (*conn, error) { | |||||||
| 		ServerConfigCallback: c.ServerConfig, | 		ServerConfigCallback: c.ServerConfig, | ||||||
| 
 | 
 | ||||||
| 		NoClientAuthHandler: c.NoClientAuthCallback, | 		NoClientAuthHandler: c.NoClientAuthCallback, | ||||||
| 		PublicKeyHandler:    c.PublicKeyHandler, |  | ||||||
| 		PasswordHandler:     c.fakePasswordHandler, | 		PasswordHandler:     c.fakePasswordHandler, | ||||||
| 
 | 
 | ||||||
| 		Handler:                       c.handleSessionPostSSHAuth, | 		Handler:                       c.handleSessionPostSSHAuth, | ||||||
| @@ -516,34 +478,6 @@ func (c *conn) mayForwardLocalPortTo(ctx ssh.Context, destinationHost string, de | |||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // havePubKeyPolicy reports whether any policy rule may provide access by means |  | ||||||
| // of a ssh.PublicKey. |  | ||||||
| func (c *conn) havePubKeyPolicy() bool { |  | ||||||
| 	if c.info == nil { |  | ||||||
| 		panic("havePubKeyPolicy called before setInfo") |  | ||||||
| 	} |  | ||||||
| 	// Is there any rule that looks like it'd require a public key for this |  | ||||||
| 	// sshUser? |  | ||||||
| 	pol, ok := c.sshPolicy() |  | ||||||
| 	if !ok { |  | ||||||
| 		return false |  | ||||||
| 	} |  | ||||||
| 	for _, r := range pol.Rules { |  | ||||||
| 		if c.ruleExpired(r) { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 		if mapLocalUser(r.SSHUsers, c.info.sshUser) == "" { |  | ||||||
| 			continue |  | ||||||
| 		} |  | ||||||
| 		for _, p := range r.Principals { |  | ||||||
| 			if len(p.PubKeys) > 0 && c.principalMatchesTailscaleIdentity(p) { |  | ||||||
| 				return true |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return false |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // sshPolicy returns the SSHPolicy for current node. | // sshPolicy returns the SSHPolicy for current node. | ||||||
| // If there is no SSHPolicy in the netmap, it returns a debugPolicy | // If there is no SSHPolicy in the netmap, it returns a debugPolicy | ||||||
| // if one is defined. | // if one is defined. | ||||||
| @@ -620,117 +554,19 @@ func (c *conn) setInfo(ctx ssh.Context) error { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // evaluatePolicy returns the SSHAction and localUser after evaluating | // evaluatePolicy returns the SSHAction and localUser after evaluating | ||||||
| // the SSHPolicy for this conn. The pubKey may be nil for "none" auth. | // the SSHPolicy for this conn. | ||||||
| func (c *conn) evaluatePolicy(pubKey gossh.PublicKey) (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) { | func (c *conn) evaluatePolicy() (_ *tailcfg.SSHAction, localUser string, acceptEnv []string, _ error) { | ||||||
| 	pol, ok := c.sshPolicy() | 	pol, ok := c.sshPolicy() | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no SSH policy") | 		return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no SSH policy") | ||||||
| 	} | 	} | ||||||
| 	a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol, pubKey) | 	a, localUser, acceptEnv, ok := c.evalSSHPolicy(pol) | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no matching policy") | 		return nil, "", nil, fmt.Errorf("tailssh: rejecting connection; no matching policy") | ||||||
| 	} | 	} | ||||||
| 	return a, localUser, acceptEnv, nil | 	return a, localUser, acceptEnv, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // pubKeyCacheEntry is the cache value for an HTTPS URL of public keys (like |  | ||||||
| // "https://github.com/foo.keys") |  | ||||||
| type pubKeyCacheEntry struct { |  | ||||||
| 	lines []string |  | ||||||
| 	etag  string // if sent by server |  | ||||||
| 	at    time.Time |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| const ( |  | ||||||
| 	pubKeyCacheDuration      = time.Minute      // how long to cache non-empty public keys |  | ||||||
| 	pubKeyCacheEmptyDuration = 15 * time.Second // how long to cache empty responses |  | ||||||
| ) |  | ||||||
| 
 |  | ||||||
| func (srv *server) fetchPublicKeysURLCached(url string) (ce pubKeyCacheEntry, ok bool) { |  | ||||||
| 	srv.mu.Lock() |  | ||||||
| 	defer srv.mu.Unlock() |  | ||||||
| 	// Mostly don't care about the size of this cache. Clean rarely. |  | ||||||
| 	if m := srv.fetchPublicKeysCache; len(m) > 50 { |  | ||||||
| 		tooOld := srv.now().Add(pubKeyCacheDuration * 10) |  | ||||||
| 		for k, ce := range m { |  | ||||||
| 			if ce.at.Before(tooOld) { |  | ||||||
| 				delete(m, k) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	ce, ok = srv.fetchPublicKeysCache[url] |  | ||||||
| 	if !ok { |  | ||||||
| 		return ce, false |  | ||||||
| 	} |  | ||||||
| 	maxAge := pubKeyCacheDuration |  | ||||||
| 	if len(ce.lines) == 0 { |  | ||||||
| 		maxAge = pubKeyCacheEmptyDuration |  | ||||||
| 	} |  | ||||||
| 	return ce, srv.now().Sub(ce.at) < maxAge |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (srv *server) pubKeyClient() *http.Client { |  | ||||||
| 	if srv.pubKeyHTTPClient != nil { |  | ||||||
| 		return srv.pubKeyHTTPClient |  | ||||||
| 	} |  | ||||||
| 	return http.DefaultClient |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // fetchPublicKeysURL fetches the public keys from a URL. The strings are in the |  | ||||||
| // the typical public key "type base64-string [comment]" format seen at e.g. |  | ||||||
| // https://github.com/USER.keys |  | ||||||
| func (srv *server) fetchPublicKeysURL(url string) ([]string, error) { |  | ||||||
| 	if !strings.HasPrefix(url, "https://") { |  | ||||||
| 		return nil, errors.New("invalid URL scheme") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ce, ok := srv.fetchPublicKeysURLCached(url) |  | ||||||
| 	if ok { |  | ||||||
| 		return ce.lines, nil |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |  | ||||||
| 	defer cancel() |  | ||||||
| 	req, err := http.NewRequestWithContext(ctx, "GET", url, nil) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	if ce.etag != "" { |  | ||||||
| 		req.Header.Add("If-None-Match", ce.etag) |  | ||||||
| 	} |  | ||||||
| 	res, err := srv.pubKeyClient().Do(req) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 	defer res.Body.Close() |  | ||||||
| 	var lines []string |  | ||||||
| 	var etag string |  | ||||||
| 	switch res.StatusCode { |  | ||||||
| 	default: |  | ||||||
| 		err = fmt.Errorf("unexpected status %v", res.Status) |  | ||||||
| 		srv.logf("fetching public keys from %s: %v", url, err) |  | ||||||
| 	case http.StatusNotModified: |  | ||||||
| 		lines = ce.lines |  | ||||||
| 		etag = ce.etag |  | ||||||
| 	case http.StatusOK: |  | ||||||
| 		var all []byte |  | ||||||
| 		all, err = io.ReadAll(io.LimitReader(res.Body, 4<<10)) |  | ||||||
| 		if s := strings.TrimSpace(string(all)); s != "" { |  | ||||||
| 			lines = strings.Split(s, "\n") |  | ||||||
| 		} |  | ||||||
| 		etag = res.Header.Get("Etag") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	srv.mu.Lock() |  | ||||||
| 	defer srv.mu.Unlock() |  | ||||||
| 	mak.Set(&srv.fetchPublicKeysCache, url, pubKeyCacheEntry{ |  | ||||||
| 		at:    srv.now(), |  | ||||||
| 		lines: lines, |  | ||||||
| 		etag:  etag, |  | ||||||
| 	}) |  | ||||||
| 	return lines, err |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication, | // handleSessionPostSSHAuth runs an SSH session after the SSH-level authentication, | ||||||
| // but not necessarily before all the Tailscale-level extra verification has | // but not necessarily before all the Tailscale-level extra verification has | ||||||
| // completed. It also handles SFTP requests. | // completed. It also handles SFTP requests. | ||||||
| @@ -832,18 +668,6 @@ func (c *conn) expandDelegateURLLocked(actionURL string) string { | |||||||
| 	).Replace(actionURL) | 	).Replace(actionURL) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *conn) expandPublicKeyURL(pubKeyURL string) string { |  | ||||||
| 	if !strings.Contains(pubKeyURL, "$") { |  | ||||||
| 		return pubKeyURL |  | ||||||
| 	} |  | ||||||
| 	loginName := c.info.uprof.LoginName |  | ||||||
| 	localPart, _, _ := strings.Cut(loginName, "@") |  | ||||||
| 	return strings.NewReplacer( |  | ||||||
| 		"$LOGINNAME_EMAIL", loginName, |  | ||||||
| 		"$LOGINNAME_LOCALPART", localPart, |  | ||||||
| 	).Replace(pubKeyURL) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // sshSession is an accepted Tailscale SSH session. | // sshSession is an accepted Tailscale SSH session. | ||||||
| type sshSession struct { | type sshSession struct { | ||||||
| 	ssh.Session | 	ssh.Session | ||||||
| @@ -894,7 +718,7 @@ func (c *conn) newSSHSession(s ssh.Session) *sshSession { | |||||||
| 
 | 
 | ||||||
| // isStillValid reports whether the conn is still valid. | // isStillValid reports whether the conn is still valid. | ||||||
| func (c *conn) isStillValid() bool { | func (c *conn) isStillValid() bool { | ||||||
| 	a, localUser, _, err := c.evaluatePolicy(c.pubKey) | 	a, localUser, _, err := c.evaluatePolicy() | ||||||
| 	c.vlogf("stillValid: %+v %v %v", a, localUser, err) | 	c.vlogf("stillValid: %+v %v %v", a, localUser, err) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false | 		return false | ||||||
| @@ -1277,9 +1101,9 @@ func (c *conn) ruleExpired(r *tailcfg.SSHRule) bool { | |||||||
| 	return r.RuleExpires.Before(c.srv.now()) | 	return r.RuleExpires.Before(c.srv.now()) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) { | func (c *conn) evalSSHPolicy(pol *tailcfg.SSHPolicy) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, ok bool) { | ||||||
| 	for _, r := range pol.Rules { | 	for _, r := range pol.Rules { | ||||||
| 		if a, localUser, acceptEnv, err := c.matchRule(r, pubKey); err == nil { | 		if a, localUser, acceptEnv, err := c.matchRule(r); err == nil { | ||||||
| 			return a, localUser, acceptEnv, true | 			return a, localUser, acceptEnv, true | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -1296,7 +1120,7 @@ var ( | |||||||
| 	errInvalidConn    = errors.New("invalid connection state") | 	errInvalidConn    = errors.New("invalid connection state") | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) { | func (c *conn) matchRule(r *tailcfg.SSHRule) (a *tailcfg.SSHAction, localUser string, acceptEnv []string, err error) { | ||||||
| 	defer func() { | 	defer func() { | ||||||
| 		c.vlogf("matchRule(%+v): %v", r, err) | 		c.vlogf("matchRule(%+v): %v", r, err) | ||||||
| 	}() | 	}() | ||||||
| @@ -1326,9 +1150,7 @@ func (c *conn) matchRule(r *tailcfg.SSHRule, pubKey gossh.PublicKey) (a *tailcfg | |||||||
| 			return nil, "", nil, errUserMatch | 			return nil, "", nil, errUserMatch | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	if ok, err := c.anyPrincipalMatches(r.Principals, pubKey); err != nil { | 	if !c.anyPrincipalMatches(r.Principals) { | ||||||
| 		return nil, "", nil, err |  | ||||||
| 	} else if !ok { |  | ||||||
| 		return nil, "", nil, errPrincipalMatch | 		return nil, "", nil, errPrincipalMatch | ||||||
| 	} | 	} | ||||||
| 	return r.Action, localUser, r.AcceptEnv, nil | 	return r.Action, localUser, r.AcceptEnv, nil | ||||||
| @@ -1345,30 +1167,20 @@ func mapLocalUser(ruleSSHUsers map[string]string, reqSSHUser string) (localUser | |||||||
| 	return v | 	return v | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) { | func (c *conn) anyPrincipalMatches(ps []*tailcfg.SSHPrincipal) bool { | ||||||
| 	for _, p := range ps { | 	for _, p := range ps { | ||||||
| 		if p == nil { | 		if p == nil { | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 		if ok, err := c.principalMatches(p, pubKey); err != nil { | 		if c.principalMatchesTailscaleIdentity(p) { | ||||||
| 			return false, err | 			return true | ||||||
| 		} else if ok { |  | ||||||
| 			return true, nil |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	return false, nil | 	return false | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (c *conn) principalMatches(p *tailcfg.SSHPrincipal, pubKey gossh.PublicKey) (bool, error) { |  | ||||||
| 	if !c.principalMatchesTailscaleIdentity(p) { |  | ||||||
| 		return false, nil |  | ||||||
| 	} |  | ||||||
| 	return c.principalMatchesPubKey(p, pubKey) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // principalMatchesTailscaleIdentity reports whether one of p's four fields | // principalMatchesTailscaleIdentity reports whether one of p's four fields | ||||||
| // that match the Tailscale identity match (Node, NodeIP, UserLogin, Any). | // that match the Tailscale identity match (Node, NodeIP, UserLogin, Any). | ||||||
| // This function does not consider PubKeys. |  | ||||||
| func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { | func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { | ||||||
| 	ci := c.info | 	ci := c.info | ||||||
| 	if p.Any { | 	if p.Any { | ||||||
| @@ -1388,42 +1200,6 @@ func (c *conn) principalMatchesTailscaleIdentity(p *tailcfg.SSHPrincipal) bool { | |||||||
| 	return false | 	return false | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *conn) principalMatchesPubKey(p *tailcfg.SSHPrincipal, clientPubKey gossh.PublicKey) (bool, error) { |  | ||||||
| 	if len(p.PubKeys) == 0 { |  | ||||||
| 		return true, nil |  | ||||||
| 	} |  | ||||||
| 	if clientPubKey == nil { |  | ||||||
| 		return false, nil |  | ||||||
| 	} |  | ||||||
| 	knownKeys := p.PubKeys |  | ||||||
| 	if len(knownKeys) == 1 && strings.HasPrefix(knownKeys[0], "https://") { |  | ||||||
| 		var err error |  | ||||||
| 		knownKeys, err = c.srv.fetchPublicKeysURL(c.expandPublicKeyURL(knownKeys[0])) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return false, err |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	for _, knownKey := range knownKeys { |  | ||||||
| 		if pubKeyMatchesAuthorizedKey(clientPubKey, knownKey) { |  | ||||||
| 			return true, nil |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return false, nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func pubKeyMatchesAuthorizedKey(pubKey ssh.PublicKey, wantKey string) bool { |  | ||||||
| 	wantKeyType, rest, ok := strings.Cut(wantKey, " ") |  | ||||||
| 	if !ok { |  | ||||||
| 		return false |  | ||||||
| 	} |  | ||||||
| 	if pubKey.Type() != wantKeyType { |  | ||||||
| 		return false |  | ||||||
| 	} |  | ||||||
| 	wantKeyB64, _, _ := strings.Cut(rest, " ") |  | ||||||
| 	wantKeyData, _ := base64.StdEncoding.DecodeString(wantKeyB64) |  | ||||||
| 	return len(wantKeyData) > 0 && bytes.Equal(pubKey.Marshal(), wantKeyData) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func randBytes(n int) []byte { | func randBytes(n int) []byte { | ||||||
| 	b := make([]byte, n) | 	b := make([]byte, n) | ||||||
| 	if _, err := rand.Read(b); err != nil { | 	if _, err := rand.Read(b); err != nil { | ||||||
| @@ -1749,7 +1525,6 @@ func envEq(a, b string) bool { | |||||||
| var ( | var ( | ||||||
| 	metricActiveSessions      = clientmetric.NewGauge("ssh_active_sessions") | 	metricActiveSessions      = clientmetric.NewGauge("ssh_active_sessions") | ||||||
| 	metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections") | 	metricIncomingConnections = clientmetric.NewCounter("ssh_incoming_connections") | ||||||
| 	metricPublicKeyAccepts    = clientmetric.NewCounter("ssh_publickey_accepts") // accepted subset of ssh_publickey_connections |  | ||||||
| 	metricTerminalAccept      = clientmetric.NewCounter("ssh_terminalaction_accept") | 	metricTerminalAccept      = clientmetric.NewCounter("ssh_terminalaction_accept") | ||||||
| 	metricTerminalReject      = clientmetric.NewCounter("ssh_terminalaction_reject") | 	metricTerminalReject      = clientmetric.NewCounter("ssh_terminalaction_reject") | ||||||
| 	metricTerminalMalformed   = clientmetric.NewCounter("ssh_terminalaction_malformed") | 	metricTerminalMalformed   = clientmetric.NewCounter("ssh_terminalaction_malformed") | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ import ( | |||||||
| 	"context" | 	"context" | ||||||
| 	"crypto/ed25519" | 	"crypto/ed25519" | ||||||
| 	"crypto/rand" | 	"crypto/rand" | ||||||
| 	"crypto/sha256" |  | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| @@ -229,7 +228,7 @@ func TestMatchRule(t *testing.T) { | |||||||
| 				info: tt.ci, | 				info: tt.ci, | ||||||
| 				srv:  &server{logf: t.Logf}, | 				srv:  &server{logf: t.Logf}, | ||||||
| 			} | 			} | ||||||
| 			got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule, nil) | 			got, gotUser, gotAcceptEnv, err := c.matchRule(tt.rule) | ||||||
| 			if err != tt.wantErr { | 			if err != tt.wantErr { | ||||||
| 				t.Errorf("err = %v; want %v", err, tt.wantErr) | 				t.Errorf("err = %v; want %v", err, tt.wantErr) | ||||||
| 			} | 			} | ||||||
| @@ -348,7 +347,7 @@ func TestEvalSSHPolicy(t *testing.T) { | |||||||
| 				info: tt.ci, | 				info: tt.ci, | ||||||
| 				srv:  &server{logf: t.Logf}, | 				srv:  &server{logf: t.Logf}, | ||||||
| 			} | 			} | ||||||
| 			got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy, nil) | 			got, gotUser, gotAcceptEnv, match := c.evalSSHPolicy(tt.policy) | ||||||
| 			if match != tt.wantMatch { | 			if match != tt.wantMatch { | ||||||
| 				t.Errorf("match = %v; want %v", match, tt.wantMatch) | 				t.Errorf("match = %v; want %v", match, tt.wantMatch) | ||||||
| 			} | 			} | ||||||
| @@ -1129,89 +1128,6 @@ func parseEnv(out []byte) map[string]string { | |||||||
| 	return e | 	return e | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestPublicKeyFetching(t *testing.T) { |  | ||||||
| 	var reqsTotal, reqsIfNoneMatchHit, reqsIfNoneMatchMiss int32 |  | ||||||
| 	ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |  | ||||||
| 		atomic.AddInt32((&reqsTotal), 1) |  | ||||||
| 		etag := fmt.Sprintf("W/%q", sha256.Sum256([]byte(r.URL.Path))) |  | ||||||
| 		w.Header().Set("Etag", etag) |  | ||||||
| 		if v := r.Header.Get("If-None-Match"); v != "" { |  | ||||||
| 			if v == etag { |  | ||||||
| 				atomic.AddInt32(&reqsIfNoneMatchHit, 1) |  | ||||||
| 				w.WriteHeader(304) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
| 			atomic.AddInt32(&reqsIfNoneMatchMiss, 1) |  | ||||||
| 		} |  | ||||||
| 		io.WriteString(w, "foo\nbar\n"+string(r.URL.Path)+"\n") |  | ||||||
| 	})) |  | ||||||
| 	ts.StartTLS() |  | ||||||
| 	defer ts.Close() |  | ||||||
| 	keys := ts.URL |  | ||||||
| 
 |  | ||||||
| 	clock := &tstest.Clock{} |  | ||||||
| 	srv := &server{ |  | ||||||
| 		pubKeyHTTPClient: ts.Client(), |  | ||||||
| 		timeNow:          clock.Now, |  | ||||||
| 	} |  | ||||||
| 	for range 2 { |  | ||||||
| 		got, err := srv.fetchPublicKeysURL(keys + "/alice.keys") |  | ||||||
| 		if err != nil { |  | ||||||
| 			t.Fatal(err) |  | ||||||
| 		} |  | ||||||
| 		if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) { |  | ||||||
| 			t.Errorf("got %q; want %q", got, want) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	if got, want := atomic.LoadInt32(&reqsTotal), int32(1); got != want { |  | ||||||
| 		t.Errorf("got %d requests; want %d", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(0); got != want { |  | ||||||
| 		t.Errorf("got %d etag hits; want %d", got, want) |  | ||||||
| 	} |  | ||||||
| 	clock.Advance(5 * time.Minute) |  | ||||||
| 	got, err := srv.fetchPublicKeysURL(keys + "/alice.keys") |  | ||||||
| 	if err != nil { |  | ||||||
| 		t.Fatal(err) |  | ||||||
| 	} |  | ||||||
| 	if want := []string{"foo", "bar", "/alice.keys"}; !reflect.DeepEqual(got, want) { |  | ||||||
| 		t.Errorf("got %q; want %q", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := atomic.LoadInt32(&reqsTotal), int32(2); got != want { |  | ||||||
| 		t.Errorf("got %d requests; want %d", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := atomic.LoadInt32(&reqsIfNoneMatchHit), int32(1); got != want { |  | ||||||
| 		t.Errorf("got %d etag hits; want %d", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := atomic.LoadInt32(&reqsIfNoneMatchMiss), int32(0); got != want { |  | ||||||
| 		t.Errorf("got %d etag misses; want %d", got, want) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func TestExpandPublicKeyURL(t *testing.T) { |  | ||||||
| 	c := &conn{ |  | ||||||
| 		info: &sshConnInfo{ |  | ||||||
| 			uprof: tailcfg.UserProfile{ |  | ||||||
| 				LoginName: "bar@baz.tld", |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
| 	if got, want := c.expandPublicKeyURL("foo"), "foo"; got != want { |  | ||||||
| 		t.Errorf("basic: got %q; want %q", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := c.expandPublicKeyURL("https://example.com/$LOGINNAME_LOCALPART.keys"), "https://example.com/bar.keys"; got != want { |  | ||||||
| 		t.Errorf("localpart: got %q; want %q", got, want) |  | ||||||
| 	} |  | ||||||
| 	if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email=bar@baz.tld"; got != want { |  | ||||||
| 		t.Errorf("email: got %q; want %q", got, want) |  | ||||||
| 	} |  | ||||||
| 	c.info = new(sshConnInfo) |  | ||||||
| 	if got, want := c.expandPublicKeyURL("https://example.com/keys?email=$LOGINNAME_EMAIL"), "https://example.com/keys?email="; got != want { |  | ||||||
| 		t.Errorf("on empty: got %q; want %q", got, want) |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func TestAcceptEnvPair(t *testing.T) { | func TestAcceptEnvPair(t *testing.T) { | ||||||
| 	tests := []struct { | 	tests := []struct { | ||||||
| 		in   string | 		in   string | ||||||
|   | |||||||
| @@ -152,7 +152,8 @@ type CapabilityVersion int | |||||||
| //   - 107: 2024-10-30: add App Connector to conffile (PR #13942) | //   - 107: 2024-10-30: add App Connector to conffile (PR #13942) | ||||||
| //   - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services. | //   - 108: 2024-11-08: Client sends ServicesHash in Hostinfo, understands c2n GET /vip-services. | ||||||
| //   - 109: 2024-11-18: Client supports filtertype.Match.SrcCaps (issue #12542) | //   - 109: 2024-11-18: Client supports filtertype.Match.SrcCaps (issue #12542) | ||||||
| const CurrentCapabilityVersion CapabilityVersion = 109 | //   - 110: 2024-12-12: removed never-before-used Tailscale SSH public key support (#14373) | ||||||
|  | const CurrentCapabilityVersion CapabilityVersion = 110 | ||||||
| 
 | 
 | ||||||
| type StableID string | type StableID string | ||||||
| 
 | 
 | ||||||
| @@ -2525,16 +2526,13 @@ type SSHPrincipal struct { | |||||||
| 	Any       bool         `json:"any,omitempty"`       // if true, match any connection | 	Any       bool         `json:"any,omitempty"`       // if true, match any connection | ||||||
| 	// TODO(bradfitz): add StableUserID, once that exists | 	// TODO(bradfitz): add StableUserID, once that exists | ||||||
| 
 | 
 | ||||||
| 	// PubKeys, if non-empty, means that this SSHPrincipal only | 	// UnusedPubKeys was public key support. It never became an official product | ||||||
| 	// matches if one of these public keys is presented by the user. | 	// feature and so as of 2024-12-12 is being removed. | ||||||
|  | 	// This stub exists to remind us not to re-use the JSON field name "pubKeys" | ||||||
|  | 	// in the future if we bring it back with different semantics. | ||||||
| 	// | 	// | ||||||
| 	// As a special case, if len(PubKeys) == 1 and PubKeys[0] starts | 	// Deprecated: do not use. It does nothing. | ||||||
| 	// with "https://", then it's fetched (like https://github.com/username.keys). | 	UnusedPubKeys []string `json:"pubKeys,omitempty"` | ||||||
| 	// In that case, the following variable expansions are also supported |  | ||||||
| 	// in the URL: |  | ||||||
| 	//   * $LOGINNAME_EMAIL ("foo@bar.com" or "foo@github") |  | ||||||
| 	//   * $LOGINNAME_LOCALPART (the "foo" from either of the above) |  | ||||||
| 	PubKeys []string `json:"pubKeys,omitempty"` |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // SSHAction is how to handle an incoming connection. | // SSHAction is how to handle an incoming connection. | ||||||
|   | |||||||
| @@ -556,17 +556,17 @@ func (src *SSHPrincipal) Clone() *SSHPrincipal { | |||||||
| 	} | 	} | ||||||
| 	dst := new(SSHPrincipal) | 	dst := new(SSHPrincipal) | ||||||
| 	*dst = *src | 	*dst = *src | ||||||
| 	dst.PubKeys = append(src.PubKeys[:0:0], src.PubKeys...) | 	dst.UnusedPubKeys = append(src.UnusedPubKeys[:0:0], src.UnusedPubKeys...) | ||||||
| 	return dst | 	return dst | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // A compilation failure here means this code must be regenerated, with the command at the top of this file. | // A compilation failure here means this code must be regenerated, with the command at the top of this file. | ||||||
| var _SSHPrincipalCloneNeedsRegeneration = SSHPrincipal(struct { | var _SSHPrincipalCloneNeedsRegeneration = SSHPrincipal(struct { | ||||||
| 	Node      StableNodeID | 	Node          StableNodeID | ||||||
| 	NodeIP    string | 	NodeIP        string | ||||||
| 	UserLogin string | 	UserLogin     string | ||||||
| 	Any       bool | 	Any           bool | ||||||
| 	PubKeys   []string | 	UnusedPubKeys []string | ||||||
| }{}) | }{}) | ||||||
| 
 | 
 | ||||||
| // Clone makes a deep copy of ControlDialPlan. | // Clone makes a deep copy of ControlDialPlan. | ||||||
|   | |||||||
| @@ -1260,19 +1260,21 @@ func (v *SSHPrincipalView) UnmarshalJSON(b []byte) error { | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (v SSHPrincipalView) Node() StableNodeID           { return v.ж.Node } | func (v SSHPrincipalView) Node() StableNodeID { return v.ж.Node } | ||||||
| func (v SSHPrincipalView) NodeIP() string               { return v.ж.NodeIP } | func (v SSHPrincipalView) NodeIP() string     { return v.ж.NodeIP } | ||||||
| func (v SSHPrincipalView) UserLogin() string            { return v.ж.UserLogin } | func (v SSHPrincipalView) UserLogin() string  { return v.ж.UserLogin } | ||||||
| func (v SSHPrincipalView) Any() bool                    { return v.ж.Any } | func (v SSHPrincipalView) Any() bool          { return v.ж.Any } | ||||||
| func (v SSHPrincipalView) PubKeys() views.Slice[string] { return views.SliceOf(v.ж.PubKeys) } | func (v SSHPrincipalView) UnusedPubKeys() views.Slice[string] { | ||||||
|  | 	return views.SliceOf(v.ж.UnusedPubKeys) | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // A compilation failure here means this code must be regenerated, with the command at the top of this file. | // A compilation failure here means this code must be regenerated, with the command at the top of this file. | ||||||
| var _SSHPrincipalViewNeedsRegeneration = SSHPrincipal(struct { | var _SSHPrincipalViewNeedsRegeneration = SSHPrincipal(struct { | ||||||
| 	Node      StableNodeID | 	Node          StableNodeID | ||||||
| 	NodeIP    string | 	NodeIP        string | ||||||
| 	UserLogin string | 	UserLogin     string | ||||||
| 	Any       bool | 	Any           bool | ||||||
| 	PubKeys   []string | 	UnusedPubKeys []string | ||||||
| }{}) | }{}) | ||||||
| 
 | 
 | ||||||
| // View returns a readonly view of ControlDialPlan. | // View returns a readonly view of ControlDialPlan. | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user