diff --git a/client/web/qnap.go b/client/web/qnap.go new file mode 100644 index 000000000..682c4d952 --- /dev/null +++ b/client/web/qnap.go @@ -0,0 +1,111 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// qnap.go contains handlers and logic, such as authentication, +// that is specific to running the web client on QNAP. + +package web + +import ( + "crypto/tls" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "net/url" +) + +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 +} diff --git a/client/web/synology.go b/client/web/synology.go new file mode 100644 index 000000000..1d8f1096e --- /dev/null +++ b/client/web/synology.go @@ -0,0 +1,71 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// synology.go contains handlers and logic, such as authentication, +// that is specific to running the web client on Synology. + +package web + +import ( + "fmt" + "net/http" + "os/exec" + "strings" + + "tailscale.com/util/groupmember" +) + +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 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 +} + +// 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 +} diff --git a/client/web/web.go b/client/web/web.go index 7c76af7b7..01dc78f3b 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -8,10 +8,8 @@ import ( "bytes" "context" "crypto/rand" - "crypto/tls" "embed" "encoding/json" - "encoding/xml" "fmt" "html/template" "io" @@ -19,9 +17,7 @@ import ( "net/http" "net/http/httputil" "net/netip" - "net/url" "os" - "os/exec" "path/filepath" "strings" @@ -33,7 +29,6 @@ import ( "tailscale.com/licenses" "tailscale.com/net/netutil" "tailscale.com/tailcfg" - "tailscale.com/util/groupmember" "tailscale.com/version/distro" ) @@ -138,122 +133,6 @@ func authorize(w http.ResponseWriter, r *http.Request) (string, error) { 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) @@ -261,39 +140,6 @@ func authRedirect(w http.ResponseWriter, r *http.Request) bool { 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... - - -` - // ServeHTTP processes all requests for the Tailscale web client. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s.devMode {