diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 56703a63b..508a89cfb 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -381,6 +381,21 @@ func CheckIPForwarding(ctx context.Context) error { return nil } +// CheckPrefs validates the provided preferences, without making any changes. +// +// The CLI uses this before a Start call to fail fast if the preferences won't +// work. Currently (2022-04-18) this only checks for SSH server compatibility. +// Note that EditPrefs does the same validation as this, so call CheckPrefs before +// EditPrefs is not necessary. +func CheckPrefs(ctx context.Context, p *ipn.Prefs) error { + pj, err := json.Marshal(p) + if err != nil { + return err + } + _, err = send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj)) + return err +} + func GetPrefs(ctx context.Context) (*ipn.Prefs, error) { body, err := get200(ctx, "/localapi/v0/prefs") if err != nil { diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index f08d631ea..b96bd9013 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -592,6 +592,10 @@ func runUp(ctx context.Context, args []string) error { return err } } else { + if err := tailscale.CheckPrefs(ctx, prefs); err != nil { + return err + } + authKey, err := upArgs.getAuthKey() if err != nil { return err diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 7b0c5fd8a..10be7a055 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1775,11 +1775,49 @@ func (b *LocalBackend) SetCurrentUserID(uid string) { b.mu.Unlock() } +func (b *LocalBackend) CheckPrefs(p *ipn.Prefs) error { + b.mu.Lock() + defer b.mu.Unlock() + return b.checkPrefsLocked(p) +} + +func (b *LocalBackend) checkPrefsLocked(p *ipn.Prefs) error { + if p.Hostname == "badhostname.tailscale." { + // Keep this one just for testing. + return errors.New("bad hostname [test]") + } + if p.RunSSH { + switch runtime.GOOS { + case "linux": + // 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 !canSSH { + return errors.New("The Tailscale SSH server has been administratively disabled.") + } + } + return nil +} + func (b *LocalBackend) EditPrefs(mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { b.mu.Lock() p0 := b.prefs.Clone() p1 := b.prefs.Clone() p1.ApplyEdits(mp) + if err := b.checkPrefsLocked(p1); err != nil { + b.mu.Unlock() + b.logf("EditPrefs check error: %v", err) + return nil, err + } if p1.RunSSH && !canSSH { b.mu.Unlock() b.logf("EditPrefs requests SSH, but disabled by envknob; returning error") diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 95fc4dadb..d3390748d 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -111,6 +111,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveLogout(w, r) case "/localapi/v0/prefs": h.servePrefs(w, r) + case "/localapi/v0/check-prefs": + h.serveCheckPrefs(w, r) case "/localapi/v0/check-ip-forwarding": h.serveCheckIPForwarding(w, r) case "/localapi/v0/bugreport": @@ -376,7 +378,9 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { var err error prefs, err = h.b.EditPrefs(mp) if err != nil { - http.Error(w, err.Error(), 400) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(resJSON{Error: err.Error()}) return } case "GET", "HEAD": @@ -391,6 +395,33 @@ func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { e.Encode(prefs) } +type resJSON struct { + Error string `json:",omitempty"` +} + +func (h *Handler) serveCheckPrefs(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "checkprefs access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "unsupported method", http.StatusMethodNotAllowed) + return + } + p := new(ipn.Prefs) + if err := json.NewDecoder(r.Body).Decode(p); err != nil { + http.Error(w, "invalid JSON body", 400) + return + } + err := h.b.CheckPrefs(p) + var res resJSON + if err != nil { + res.Error = err.Error() + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} + func (h *Handler) serveFiles(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "file access denied", http.StatusForbidden)