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>main
parent
69f1324c9e
commit
f9066ac1f4
@ -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 |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue