From f9066ac1f4ec2f6d3471af0786678048396c01ac Mon Sep 17 00:00:00 2001 From: Will Norris Date: Tue, 8 Aug 2023 16:58:45 -0700 Subject: [PATCH] client/web: extract web client from cli package move the tailscale web client out of the cmd/tailscale/cli package, into a new client/web package. The remaining cli/web.go file is still responsible for parsing CLI flags and such, and then calls into client/web. This will allow the web client to be hooked into from other contexts (for example, from a tsnet server), and provide a dedicated space to add more functionality to this client. Updates tailscale/corp#13775 Signed-off-by: Will Norris --- .../cli => client/web}/auth-redirect.html | 0 {cmd/tailscale/cli => client/web}/web.css | 0 client/web/web.go | 446 ++++++++++++++++++ {cmd/tailscale/cli => client/web}/web.html | 0 client/web/web_test.go | 64 +++ cmd/tailscale/cli/web.go | 434 +---------------- cmd/tailscale/cli/web_test.go | 56 --- cmd/tailscale/depaware.txt | 9 +- 8 files changed, 521 insertions(+), 488 deletions(-) rename {cmd/tailscale/cli => client/web}/auth-redirect.html (100%) rename {cmd/tailscale/cli => client/web}/web.css (100%) create mode 100644 client/web/web.go rename {cmd/tailscale/cli => client/web}/web.html (100%) create mode 100644 client/web/web_test.go diff --git a/cmd/tailscale/cli/auth-redirect.html b/client/web/auth-redirect.html similarity index 100% rename from cmd/tailscale/cli/auth-redirect.html rename to client/web/auth-redirect.html diff --git a/cmd/tailscale/cli/web.css b/client/web/web.css similarity index 100% rename from cmd/tailscale/cli/web.css rename to client/web/web.css diff --git a/client/web/web.go b/client/web/web.go new file mode 100644 index 000000000..d10451cbc --- /dev/null +++ b/client/web/web.go @@ -0,0 +1,446 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package web provides the Tailscale client for web. +package web + +import ( + "bytes" + "context" + "crypto/tls" + _ "embed" + "encoding/json" + "encoding/xml" + "fmt" + "html/template" + "io" + "log" + "net/http" + "net/netip" + "net/url" + "os" + "os/exec" + "strings" + + "tailscale.com/client/tailscale" + "tailscale.com/envknob" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/licenses" + "tailscale.com/net/netutil" + "tailscale.com/tailcfg" + "tailscale.com/util/groupmember" + "tailscale.com/version/distro" +) + +//go:embed web.html +var webHTML string + +//go:embed web.css +var webCSS string + +//go:embed auth-redirect.html +var authenticationRedirectHTML string + +var tmpl *template.Template + +var localClient tailscale.LocalClient + +func init() { + tmpl = template.Must(template.New("web.html").Parse(webHTML)) + template.Must(tmpl.New("web.css").Parse(webCSS)) +} + +type tmplData struct { + Profile tailcfg.UserProfile + SynologyUser string + Status string + DeviceName string + IP string + AdvertiseExitNode bool + AdvertiseRoutes string + LicensesURL string + TUNMode bool + IsSynology bool + DSMVersion int // 6 or 7, if IsSynology=true + IsUnraid bool + UnraidToken string + IPNVersion string +} + +type postedData struct { + AdvertiseRoutes string + AdvertiseExitNode bool + Reauthenticate bool + ForceLogout bool +} + +// authorize returns the name of the user accessing the web UI after verifying +// whether the user has access to the web UI. The function will write the +// error to the provided http.ResponseWriter. +// Note: This is different from a tailscale user, and is typically the local +// user on the node. +func authorize(w http.ResponseWriter, r *http.Request) (string, error) { + switch distro.Get() { + case distro.Synology: + user, err := synoAuthn() + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return "", err + } + if err := authorizeSynology(user); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return "", err + } + return user, nil + case distro.QNAP: + user, resp, err := qnapAuthn(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return "", err + } + if resp.IsAdmin == 0 { + http.Error(w, err.Error(), http.StatusForbidden) + return "", err + } + return user, nil + } + return "", nil +} + +// authorizeSynology checks whether the provided user has access to the web UI +// by consulting the membership of the "administrators" group. +func authorizeSynology(name string) error { + yes, err := groupmember.IsMemberOfGroup("administrators", name) + if err != nil { + return err + } + if !yes { + return fmt.Errorf("not a member of administrators group") + } + return nil +} + +type qnapAuthResponse struct { + AuthPassed int `xml:"authPassed"` + IsAdmin int `xml:"isAdmin"` + AuthSID string `xml:"authSid"` + ErrorValue int `xml:"errorValue"` +} + +func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) { + user, err := r.Cookie("NAS_USER") + if err != nil { + return "", nil, err + } + token, err := r.Cookie("qtoken") + if err == nil { + return qnapAuthnQtoken(r, user.Value, token.Value) + } + sid, err := r.Cookie("NAS_SID") + if err == nil { + return qnapAuthnSid(r, user.Value, sid.Value) + } + return "", nil, fmt.Errorf("not authenticated by any mechanism") +} + +// qnapAuthnURL returns the auth URL to use by inferring where the UI is +// running based on the request URL. This is necessary because QNAP has so +// many options, see https://github.com/tailscale/tailscale/issues/7108 +// and https://github.com/tailscale/tailscale/issues/6903 +func qnapAuthnURL(requestUrl string, query url.Values) string { + in, err := url.Parse(requestUrl) + scheme := "" + host := "" + if err != nil || in.Scheme == "" { + log.Printf("Cannot parse QNAP login URL %v", err) + + // try localhost and hope for the best + scheme = "http" + host = "localhost" + } else { + scheme = in.Scheme + host = in.Host + } + + u := url.URL{ + Scheme: scheme, + Host: host, + Path: "/cgi-bin/authLogin.cgi", + RawQuery: query.Encode(), + } + + return u.String() +} + +func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) { + query := url.Values{ + "qtoken": []string{token}, + "user": []string{user}, + } + return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) +} + +func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) { + query := url.Values{ + "sid": []string{sid}, + } + return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) +} + +func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) { + // QNAP Force HTTPS mode uses a self-signed certificate. Even importing + // the QNAP root CA isn't enough, the cert doesn't have a usable CN nor + // SAN. See https://github.com/tailscale/tailscale/issues/6903 + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client := &http.Client{Transport: tr} + resp, err := client.Get(url) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + out, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, err + } + authResp := &qnapAuthResponse{} + if err := xml.Unmarshal(out, authResp); err != nil { + return "", nil, err + } + if authResp.AuthPassed == 0 { + return "", nil, fmt.Errorf("not authenticated") + } + return user, authResp, nil +} + +func synoAuthn() (string, error) { + cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("auth: %v: %s", err, out) + } + return strings.TrimSpace(string(out)), nil +} + +func authRedirect(w http.ResponseWriter, r *http.Request) bool { + if distro.Get() == distro.Synology { + return synoTokenRedirect(w, r) + } + return false +} + +func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("X-Syno-Token") != "" { + return false + } + if r.URL.Query().Get("SynoToken") != "" { + return false + } + if r.Method == "POST" && r.FormValue("SynoToken") != "" { + return false + } + // We need a SynoToken for authenticate.cgi. + // So we tell the client to get one. + _, _ = fmt.Fprint(w, synoTokenRedirectHTML) + return true +} + +const synoTokenRedirectHTML = ` +Redirecting with session token... + + +` + +// Handle processes all requests for the Tailscale web client. +func Handle(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if authRedirect(w, r) { + return + } + + user, err := authorize(w, r) + if err != nil { + return + } + + if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" { + io.WriteString(w, authenticationRedirectHTML) + return + } + + st, err := localClient.StatusWithoutPeers(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + prefs, err := localClient.GetPrefs(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if r.Method == "POST" { + defer r.Body.Close() + var postData postedData + type mi map[string]any + if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + + routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + mp := &ipn.MaskedPrefs{ + AdvertiseRoutesSet: true, + WantRunningSet: true, + } + mp.Prefs.WantRunning = true + mp.Prefs.AdvertiseRoutes = routes + log.Printf("Doing edit: %v", mp.Pretty()) + + if _, err := localClient.EditPrefs(ctx, mp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + var reauth, logout bool + if postData.Reauthenticate { + reauth = true + } + if postData.ForceLogout { + logout = true + } + log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) + url, err := tailscaleUp(r.Context(), st, postData) + log.Printf("tailscaleUp = (URL %v, %v)", url != "", err) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + if url != "" { + json.NewEncoder(w).Encode(mi{"url": url}) + } else { + io.WriteString(w, "{}") + } + return + } + + profile := st.User[st.Self.UserID] + deviceName := strings.Split(st.Self.DNSName, ".")[0] + versionShort := strings.Split(st.Version, "-")[0] + data := tmplData{ + SynologyUser: user, + Profile: profile, + Status: st.BackendState, + DeviceName: deviceName, + LicensesURL: licenses.LicensesURL(), + TUNMode: st.TUN, + IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"), + DSMVersion: distro.DSMVersion(), + IsUnraid: distro.Get() == distro.Unraid, + UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), + IPNVersion: versionShort, + } + exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0") + exitNodeRouteV6 := netip.MustParsePrefix("::/0") + for _, r := range prefs.AdvertiseRoutes { + if r == exitNodeRouteV4 || r == exitNodeRouteV6 { + data.AdvertiseExitNode = true + } else { + if data.AdvertiseRoutes != "" { + data.AdvertiseRoutes += "," + } + data.AdvertiseRoutes += r.String() + } + } + if len(st.TailscaleIPs) != 0 { + data.IP = st.TailscaleIPs[0].String() + } + + buf := new(bytes.Buffer) + if err := tmpl.Execute(buf, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(buf.Bytes()) +} + +func tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) { + if postData.ForceLogout { + if err := localClient.Logout(ctx); err != nil { + return "", fmt.Errorf("Logout error: %w", err) + } + return "", nil + } + + origAuthURL := st.AuthURL + isRunning := st.BackendState == ipn.Running.String() + + forceReauth := postData.Reauthenticate + if !forceReauth { + if origAuthURL != "" { + return origAuthURL, nil + } + if isRunning { + return "", nil + } + } + + // printAuthURL reports whether we should print out the + // provided auth URL from an IPN notify. + printAuthURL := func(url string) bool { + return url != origAuthURL + } + + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + watcher, err := localClient.WatchIPNBus(watchCtx, 0) + if err != nil { + return "", err + } + defer watcher.Close() + + go func() { + if !isRunning { + localClient.Start(ctx, ipn.Options{}) + } + if forceReauth { + localClient.StartLoginInteractive(ctx) + } + }() + + for { + n, err := watcher.Next() + if err != nil { + return "", err + } + if n.ErrMessage != nil { + msg := *n.ErrMessage + return "", fmt.Errorf("backend error: %v", msg) + } + if url := n.BrowseToURL; url != nil && printAuthURL(*url) { + return *url, nil + } + } +} diff --git a/cmd/tailscale/cli/web.html b/client/web/web.html similarity index 100% rename from cmd/tailscale/cli/web.html rename to client/web/web.html diff --git a/client/web/web_test.go b/client/web/web_test.go new file mode 100644 index 000000000..858a29665 --- /dev/null +++ b/client/web/web_test.go @@ -0,0 +1,64 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package web + +import ( + "net/url" + "testing" +) + +func TestQnapAuthnURL(t *testing.T) { + query := url.Values{ + "qtoken": []string{"token"}, + } + tests := []struct { + name string + in string + want string + }{ + { + name: "localhost http", + in: "http://localhost:8088/", + want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "localhost https", + in: "https://localhost:5000/", + want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "IP http", + in: "http://10.1.20.4:80/", + want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "IP6 https", + in: "https://[ff7d:0:1:2::1]/", + want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "hostname https", + in: "https://qnap.example.com/", + want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "invalid URL", + in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.", + want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", + }, + { + name: "err != nil", + 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) { + u := qnapAuthnURL(tt.in, query) + if u != tt.want { + t.Errorf("expected url: %q, got: %q", tt.want, u) + } + }) + } +} diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index 04ae3aa92..ab591d78c 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -4,78 +4,23 @@ package cli import ( - "bytes" "context" "crypto/tls" _ "embed" - "encoding/json" - "encoding/xml" "flag" "fmt" - "html/template" - "io" "log" "net" "net/http" "net/http/cgi" - "net/netip" - "net/url" "os" - "os/exec" "strings" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/envknob" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnstate" - "tailscale.com/licenses" - "tailscale.com/net/netutil" - "tailscale.com/tailcfg" + "tailscale.com/client/web" "tailscale.com/util/cmpx" - "tailscale.com/util/groupmember" - "tailscale.com/version/distro" ) -//go:embed web.html -var webHTML string - -//go:embed web.css -var webCSS string - -//go:embed auth-redirect.html -var authenticationRedirectHTML string - -var tmpl *template.Template - -func init() { - tmpl = template.Must(template.New("web.html").Parse(webHTML)) - template.Must(tmpl.New("web.css").Parse(webCSS)) -} - -type tmplData struct { - Profile tailcfg.UserProfile - SynologyUser string - Status string - DeviceName string - IP string - AdvertiseExitNode bool - AdvertiseRoutes string - LicensesURL string - TUNMode bool - IsSynology bool - DSMVersion int // 6 or 7, if IsSynology=true - IsUnraid bool - UnraidToken string - IPNVersion string -} - -type postedData struct { - AdvertiseRoutes string - AdvertiseExitNode bool - Reauthenticate bool - ForceLogout bool -} - var webCmd = &ffcli.Command{ Name: "web", ShortUsage: "web [flags]", @@ -131,8 +76,10 @@ func runWeb(ctx context.Context, args []string) error { return fmt.Errorf("too many non-flag arguments: %q", args) } + webHandler := http.HandlerFunc(web.Handle) + if webArgs.cgi { - if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil { + if err := cgi.Serve(webHandler); err != nil { log.Printf("tailscale.cgi: %v", err) return err } @@ -144,14 +91,14 @@ func runWeb(ctx context.Context, args []string) error { server := &http.Server{ Addr: webArgs.listen, TLSConfig: tlsConfig, - Handler: http.HandlerFunc(webHandler), + Handler: webHandler, } log.Printf("web server running on: https://%s", server.Addr) return server.ListenAndServeTLS("", "") } else { log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen)) - return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler)) + return http.ListenAndServe(webArgs.listen, webHandler) } } @@ -160,372 +107,3 @@ func urlOfListenAddr(addr string) string { host, port, _ := net.SplitHostPort(addr) return fmt.Sprintf("http://%s", net.JoinHostPort(cmpx.Or(host, "127.0.0.1"), port)) } - -// authorize returns the name of the user accessing the web UI after verifying -// whether the user has access to the web UI. The function will write the -// error to the provided http.ResponseWriter. -// Note: This is different from a tailscale user, and is typically the local -// user on the node. -func authorize(w http.ResponseWriter, r *http.Request) (string, error) { - switch distro.Get() { - case distro.Synology: - user, err := synoAuthn() - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return "", err - } - if err := authorizeSynology(user); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return "", err - } - return user, nil - case distro.QNAP: - user, resp, err := qnapAuthn(r) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return "", err - } - if resp.IsAdmin == 0 { - http.Error(w, err.Error(), http.StatusForbidden) - return "", err - } - return user, nil - } - return "", nil -} - -// authorizeSynology checks whether the provided user has access to the web UI -// by consulting the membership of the "administrators" group. -func authorizeSynology(name string) error { - yes, err := groupmember.IsMemberOfGroup("administrators", name) - if err != nil { - return err - } - if !yes { - return fmt.Errorf("not a member of administrators group") - } - return nil -} - -type qnapAuthResponse struct { - AuthPassed int `xml:"authPassed"` - IsAdmin int `xml:"isAdmin"` - AuthSID string `xml:"authSid"` - ErrorValue int `xml:"errorValue"` -} - -func qnapAuthn(r *http.Request) (string, *qnapAuthResponse, error) { - user, err := r.Cookie("NAS_USER") - if err != nil { - return "", nil, err - } - token, err := r.Cookie("qtoken") - if err == nil { - return qnapAuthnQtoken(r, user.Value, token.Value) - } - sid, err := r.Cookie("NAS_SID") - if err == nil { - return qnapAuthnSid(r, user.Value, sid.Value) - } - return "", nil, fmt.Errorf("not authenticated by any mechanism") -} - -// qnapAuthnURL returns the auth URL to use by inferring where the UI is -// running based on the request URL. This is necessary because QNAP has so -// many options, see https://github.com/tailscale/tailscale/issues/7108 -// and https://github.com/tailscale/tailscale/issues/6903 -func qnapAuthnURL(requestUrl string, query url.Values) string { - in, err := url.Parse(requestUrl) - scheme := "" - host := "" - if err != nil || in.Scheme == "" { - log.Printf("Cannot parse QNAP login URL %v", err) - - // try localhost and hope for the best - scheme = "http" - host = "localhost" - } else { - scheme = in.Scheme - host = in.Host - } - - u := url.URL{ - Scheme: scheme, - Host: host, - Path: "/cgi-bin/authLogin.cgi", - RawQuery: query.Encode(), - } - - return u.String() -} - -func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) { - query := url.Values{ - "qtoken": []string{token}, - "user": []string{user}, - } - return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) -} - -func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) { - query := url.Values{ - "sid": []string{sid}, - } - return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query)) -} - -func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) { - // QNAP Force HTTPS mode uses a self-signed certificate. Even importing - // the QNAP root CA isn't enough, the cert doesn't have a usable CN nor - // SAN. See https://github.com/tailscale/tailscale/issues/6903 - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - client := &http.Client{Transport: tr} - resp, err := client.Get(url) - if err != nil { - return "", nil, err - } - defer resp.Body.Close() - out, err := io.ReadAll(resp.Body) - if err != nil { - return "", nil, err - } - authResp := &qnapAuthResponse{} - if err := xml.Unmarshal(out, authResp); err != nil { - return "", nil, err - } - if authResp.AuthPassed == 0 { - return "", nil, fmt.Errorf("not authenticated") - } - return user, authResp, nil -} - -func synoAuthn() (string, error) { - cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi") - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("auth: %v: %s", err, out) - } - return strings.TrimSpace(string(out)), nil -} - -func authRedirect(w http.ResponseWriter, r *http.Request) bool { - if distro.Get() == distro.Synology { - return synoTokenRedirect(w, r) - } - return false -} - -func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { - if r.Header.Get("X-Syno-Token") != "" { - return false - } - if r.URL.Query().Get("SynoToken") != "" { - return false - } - if r.Method == "POST" && r.FormValue("SynoToken") != "" { - return false - } - // We need a SynoToken for authenticate.cgi. - // So we tell the client to get one. - _, _ = fmt.Fprint(w, synoTokenRedirectHTML) - return true -} - -const synoTokenRedirectHTML = ` -Redirecting with session token... - - -` - -func webHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if authRedirect(w, r) { - return - } - - user, err := authorize(w, r) - if err != nil { - return - } - - if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" { - io.WriteString(w, authenticationRedirectHTML) - return - } - - st, err := localClient.StatusWithoutPeers(ctx) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - prefs, err := localClient.GetPrefs(ctx) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if r.Method == "POST" { - defer r.Body.Close() - var postData postedData - type mi map[string]any - if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { - w.WriteHeader(400) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - - routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - mp := &ipn.MaskedPrefs{ - AdvertiseRoutesSet: true, - WantRunningSet: true, - } - mp.Prefs.WantRunning = true - mp.Prefs.AdvertiseRoutes = routes - log.Printf("Doing edit: %v", mp.Pretty()) - - if _, err := localClient.EditPrefs(ctx, mp); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - - w.Header().Set("Content-Type", "application/json") - var reauth, logout bool - if postData.Reauthenticate { - reauth = true - } - if postData.ForceLogout { - logout = true - } - log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) - url, err := tailscaleUp(r.Context(), st, postData) - log.Printf("tailscaleUp = (URL %v, %v)", url != "", err) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - if url != "" { - json.NewEncoder(w).Encode(mi{"url": url}) - } else { - io.WriteString(w, "{}") - } - return - } - - profile := st.User[st.Self.UserID] - deviceName := strings.Split(st.Self.DNSName, ".")[0] - versionShort := strings.Split(st.Version, "-")[0] - data := tmplData{ - SynologyUser: user, - Profile: profile, - Status: st.BackendState, - DeviceName: deviceName, - LicensesURL: licenses.LicensesURL(), - TUNMode: st.TUN, - IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"), - DSMVersion: distro.DSMVersion(), - IsUnraid: distro.Get() == distro.Unraid, - UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"), - IPNVersion: versionShort, - } - exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0") - exitNodeRouteV6 := netip.MustParsePrefix("::/0") - for _, r := range prefs.AdvertiseRoutes { - if r == exitNodeRouteV4 || r == exitNodeRouteV6 { - data.AdvertiseExitNode = true - } else { - if data.AdvertiseRoutes != "" { - data.AdvertiseRoutes += "," - } - data.AdvertiseRoutes += r.String() - } - } - if len(st.TailscaleIPs) != 0 { - data.IP = st.TailscaleIPs[0].String() - } - - buf := new(bytes.Buffer) - if err := tmpl.Execute(buf, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Write(buf.Bytes()) -} - -func tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) { - if postData.ForceLogout { - if err := localClient.Logout(ctx); err != nil { - return "", fmt.Errorf("Logout error: %w", err) - } - return "", nil - } - - origAuthURL := st.AuthURL - isRunning := st.BackendState == ipn.Running.String() - - forceReauth := postData.Reauthenticate - if !forceReauth { - if origAuthURL != "" { - return origAuthURL, nil - } - if isRunning { - return "", nil - } - } - - // printAuthURL reports whether we should print out the - // provided auth URL from an IPN notify. - printAuthURL := func(url string) bool { - return url != origAuthURL - } - - watchCtx, cancelWatch := context.WithCancel(ctx) - defer cancelWatch() - watcher, err := localClient.WatchIPNBus(watchCtx, 0) - if err != nil { - return "", err - } - defer watcher.Close() - - go func() { - if !isRunning { - localClient.Start(ctx, ipn.Options{}) - } - if forceReauth { - localClient.StartLoginInteractive(ctx) - } - }() - - for { - n, err := watcher.Next() - if err != nil { - return "", err - } - if n.ErrMessage != nil { - msg := *n.ErrMessage - return "", fmt.Errorf("backend error: %v", msg) - } - if url := n.BrowseToURL; url != nil && printAuthURL(*url) { - return *url, nil - } - } -} diff --git a/cmd/tailscale/cli/web_test.go b/cmd/tailscale/cli/web_test.go index 8cf19daf2..f2470b364 100644 --- a/cmd/tailscale/cli/web_test.go +++ b/cmd/tailscale/cli/web_test.go @@ -4,7 +4,6 @@ package cli import ( - "net/url" "testing" ) @@ -44,58 +43,3 @@ func TestUrlOfListenAddr(t *testing.T) { }) } } - -func TestQnapAuthnURL(t *testing.T) { - query := url.Values{ - "qtoken": []string{"token"}, - } - tests := []struct { - name string - in string - want string - }{ - { - name: "localhost http", - in: "http://localhost:8088/", - want: "http://localhost:8088/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "localhost https", - in: "https://localhost:5000/", - want: "https://localhost:5000/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "IP http", - in: "http://10.1.20.4:80/", - want: "http://10.1.20.4:80/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "IP6 https", - in: "https://[ff7d:0:1:2::1]/", - want: "https://[ff7d:0:1:2::1]/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "hostname https", - in: "https://qnap.example.com/", - want: "https://qnap.example.com/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "invalid URL", - in: "This is not a URL, it is a really really really really really really really really really really really really long string to exercise the URL truncation code in the error path.", - want: "http://localhost/cgi-bin/authLogin.cgi?qtoken=token", - }, - { - name: "err != nil", - 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) { - u := qnapAuthnURL(tt.in, query) - if u != tt.want { - t.Errorf("expected url: %q, got: %q", tt.want, u) - } - }) - } -} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index d8af6cb42..11b486a8c 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -68,6 +68,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+ tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+ + tailscale.com/client/web from tailscale.com/cmd/tailscale/cli tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale tailscale.com/control/controlbase from tailscale.com/control/controlhttp tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli @@ -81,7 +82,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/hostinfo from tailscale.com/net/interfaces+ tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+ tailscale.com/ipn/ipnstate from tailscale.com/cmd/tailscale/cli+ - tailscale.com/licenses from tailscale.com/cmd/tailscale/cli + tailscale.com/licenses from tailscale.com/cmd/tailscale/cli+ tailscale.com/metrics from tailscale.com/derp tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback tailscale.com/net/dnscache from tailscale.com/derp/derphttp+ @@ -135,7 +136,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+ L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ - tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli + tailscale.com/util/groupmember from tailscale.com/client/web tailscale.com/util/httpm from tailscale.com/client/tailscale tailscale.com/util/lineread from tailscale.com/net/interfaces+ L tailscale.com/util/linuxfw from tailscale.com/net/netns @@ -235,7 +236,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep encoding/hex from crypto/x509+ encoding/json from expvar+ encoding/pem from crypto/tls+ - encoding/xml from tailscale.com/cmd/tailscale/cli+ + encoding/xml from github.com/tailscale/goupnp+ errors from bufio+ expvar from tailscale.com/derp+ flag from github.com/peterbourgon/ff/v3+ @@ -245,7 +246,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep hash/crc32 from compress/gzip+ hash/maphash from go4.org/mem html from tailscale.com/ipn/ipnstate+ - html/template from tailscale.com/cmd/tailscale/cli + html/template from tailscale.com/client/web image from github.com/skip2/go-qrcode+ image/color from github.com/skip2/go-qrcode+ image/png from github.com/skip2/go-qrcode