summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorWill Norris <will@tailscale.com>2023-08-08 16:58:45 -0700
committerWill Norris <will@willnorris.com>2023-08-09 09:53:37 -0700
commitf9066ac1f4ec2f6d3471af0786678048396c01ac (patch)
tree3aefa532d6e196b6848335be114925108f3d1e46
parent69f1324c9e3b169bb8e71cf4584a7c276545e95d (diff)
downloadtailscale-f9066ac1f4ec2f6d3471af0786678048396c01ac.tar.xz
tailscale-f9066ac1f4ec2f6d3471af0786678048396c01ac.zip
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 <will@tailscale.com>
-rw-r--r--client/web/auth-redirect.html (renamed from cmd/tailscale/cli/auth-redirect.html)0
-rw-r--r--client/web/web.css (renamed from cmd/tailscale/cli/web.css)0
-rw-r--r--client/web/web.go446
-rw-r--r--client/web/web.html (renamed from cmd/tailscale/cli/web.html)0
-rw-r--r--client/web/web_test.go64
-rw-r--r--cmd/tailscale/cli/web.go434
-rw-r--r--cmd/tailscale/cli/web_test.go56
-rw-r--r--cmd/tailscale/depaware.txt9
8 files changed, 521 insertions, 488 deletions
diff --git a/cmd/tailscale/cli/auth-redirect.html b/client/web/auth-redirect.html
index 559d8fb4f..559d8fb4f 100644
--- a/cmd/tailscale/cli/auth-redirect.html
+++ b/client/web/auth-redirect.html
diff --git a/cmd/tailscale/cli/web.css b/client/web/web.css
index 5b9d9e0b6..5b9d9e0b6 100644
--- a/cmd/tailscale/cli/web.css
+++ b/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 = `<html><body>
+Redirecting with session token...
+<script>
+var serverURL = window.location.protocol + "//" + window.location.host;
+var req = new XMLHttpRequest();
+req.overrideMimeType("application/json");
+req.open("GET", serverURL + "/webman/login.cgi", true);
+req.onload = function() {
+ var jsonResponse = JSON.parse(req.responseText);
+ var token = jsonResponse["SynoToken"];
+ document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
+};
+req.send(null);
+</script>
+</body></html>
+`
+
+// 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
index b990bdd77..b990bdd77 100644
--- a/cmd/tailscale/cli/web.html
+++ b/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 = `<html><body>
-Redirecting with session token...
-<script>
-var serverURL = window.location.protocol + "//" + window.location.host;
-var req = new XMLHttpRequest();
-req.overrideMimeType("application/json");
-req.open("GET", serverURL + "/webman/login.cgi", true);
-req.onload = function() {
- var jsonResponse = JSON.parse(req.responseText);
- var token = jsonResponse["SynoToken"];
- document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
-};
-req.send(null);
-</script>
-</body></html>
-`
-
-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