diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 8f9f93516..f768971f3 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -404,7 +404,7 @@ func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, jus return simpleUp, justEditMP, nil } -func runUp(ctx context.Context, args []string) error { +func runUp(ctx context.Context, args []string) (retErr error) { if len(args) > 0 { fatalf("too many non-flag arguments: %q", args) } @@ -481,6 +481,12 @@ func runUp(ctx context.Context, args []string) error { } } + defer func() { + if retErr == nil { + checkSSHUpWarnings(ctx) + } + }() + simpleUp, justEditMP, err := updatePrefs(prefs, curPrefs, env) if err != nil { fatalf("%s", err) @@ -676,6 +682,28 @@ func runUp(ctx context.Context, args []string) error { } } +func checkSSHUpWarnings(ctx context.Context) { + if !upArgs.runSSH { + return + } + st, err := localClient.Status(ctx) + if err != nil { + // Ignore. Don't spam more. + return + } + if len(st.Health) == 0 { + return + } + if len(st.Health) == 1 && strings.Contains(st.Health[0], "SSH") { + printf("%s\n", st.Health[0]) + return + } + printf("# Health check:\n") + for _, m := range st.Health { + printf(" - %s\n", m) + } +} + func printUpDoneJSON(state ipn.State, errorString string) { js := &upOutputJSON{BackendState: state.String(), Error: errorString} data, err := json.MarshalIndent(js, "", " ") diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 70b2cb864..975954121 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -416,6 +416,9 @@ func (b *LocalBackend) updateStatus(sb *ipnstate.StatusBuilder, extraLocked func s.Health = append(s.Health, err.Error()) } } + if m := b.sshOnButUnusableHealthCheckMessageLocked(); m != "" { + s.Health = append(s.Health, m) + } if b.netMap != nil { s.CertDomains = append([]string(nil), b.netMap.DNS.CertDomains...) s.MagicDNSSuffix = b.netMap.MagicDNSSuffix() @@ -1840,39 +1843,88 @@ func (b *LocalBackend) CheckPrefs(p *ipn.Prefs) error { } func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error { + var errs []error if p.Hostname == "badhostname.tailscale." { // Keep this one just for testing. - return errors.New("bad hostname [test]") + errs = append(errs, errors.New("bad hostname [test]")) } - if p.RunSSH { - switch runtime.GOOS { - case "linux": - if distro.Get() == distro.Synology && !envknob.UseWIPCode() { - return errors.New("The Tailscale SSH server does not run on Synology.") - } - // otherwise okay - case "darwin": - // okay only in tailscaled mode for now. - 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") - } - default: - return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS) + if err := b.checkSSHPrefsLocked(p); err != nil { + errs = append(errs, err) + } + return multierr.New(errs...) +} + +func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error { + if !p.RunSSH { + return nil + } + switch runtime.GOOS { + case "linux": + if distro.Get() == distro.Synology && !envknob.UseWIPCode() { + return errors.New("The Tailscale SSH server does not run on Synology.") + } + // otherwise okay + case "darwin": + // okay only in tailscaled mode for now. + if version.IsSandboxedMacOS() { + return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.") } - if !canSSH { - return errors.New("The Tailscale SSH server has been administratively disabled.") + 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") } - if b.netMap != nil && b.netMap.SSHPolicy == nil && - envknob.SSHPolicyFile() == "" && !envknob.SSHIgnoreTailnetPolicy() { - return errors.New("Unable to enable local Tailscale SSH server; not enabled/configured on Tailnet.") + default: + return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS) + } + if !canSSH { + return errors.New("The Tailscale SSH server has been administratively disabled.") + } + if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" { + return nil + } + if b.netMap != nil { + if !hasCapability(b.netMap, tailcfg.CapabilitySSH) { + if b.isDefaultServerLocked() { + return errors.New("Unable to enable local Tailscale SSH server; not enabled on Tailnet. See https://tailscale.com/s/ssh") + } + return errors.New("Unable to enable local Tailscale SSH server; not enabled on Tailnet.") } } return nil } +func (b *LocalBackend) sshOnButUnusableHealthCheckMessageLocked() (healthMessage string) { + if b.prefs == nil || !b.prefs.RunSSH { + return "" + } + if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" { + return "development SSH policy in use" + } + nm := b.netMap + if nm == nil { + return "" + } + if nm.SSHPolicy != nil && len(nm.SSHPolicy.Rules) > 0 { + return "" + } + isDefault := b.isDefaultServerLocked() + isAdmin := hasCapability(nm, tailcfg.CapabilityAdmin) + + if !isAdmin { + return "Tailscale SSH enabled, but access controls don't allow anyone to access this device. Ask your admin to update your tailnet's ACLs to allow access." + } + if !isDefault { + return "Tailscale SSH enabled, but access controls don't allow anyone to access this device. Update your tailnet's ACLs to allow access." + } + return "Tailscale SSH enabled, but access controls don't allow anyone to access this device. Update your tailnet's ACLs at https://tailscale.com/s/ssh-policy" +} + +func (b *LocalBackend) isDefaultServerLocked() bool { + if b.prefs == nil { + return true // assume true until set otherwise + } + return b.prefs.ControlURLOrDefault() == ipn.DefaultControlURL +} + func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { b.mu.Lock() p0 := b.prefs.Clone()