mirror of
https://github.com/tailscale/tailscale.git
synced 2024-12-02 06:25:37 +00:00
dc8287ab3b
Synology and QNAP both run the web client as a CGI script. The old web client didn't care too much about requests paths, since there was only a single GET and POST handler. The new client serves assets on different paths, so now we need to care. First, enforce that the CGI script is always accessed from its full path, including a trailing slash (e.g. /cgi-bin/tailscale/index.cgi/). Then, strip that prefix off before passing the request along to the main serve handler. This allows for properly serving both static files and the API handler in a CGI environment. Also add a CGIPath option to allow other CGI environments to specify a custom path. Finally, update vite and one "api/data" call to no longer assume that we are always serving at the root path of "/". Updates tailscale/corp#13775 Signed-off-by: Will Norris <will@tailscale.com>
131 lines
3.4 KiB
Go
131 lines
3.4 KiB
Go
// 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
|
|
}
|