// 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" ) const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/" // authorizeQNAP authenticates the logged-in QNAP user and verifies // that they are authorized to use the web client. It returns true if the // request was handled and no further processing is required. func authorizeQNAP(w http.ResponseWriter, r *http.Request) (handled bool) { _, resp, err := qnapAuthn(r) if err != nil { http.Error(w, err.Error(), http.StatusUnauthorized) return true } if resp.IsAdmin == 0 { http.Error(w, "user is not an admin", http.StatusForbidden) return true } return false } 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 }