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>
This commit is contained in:
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,78 +4,23 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
|
||||||
"encoding/xml"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cgi"
|
"net/http/cgi"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/client/web"
|
||||||
"tailscale.com/ipn"
|
|
||||||
"tailscale.com/ipn/ipnstate"
|
|
||||||
"tailscale.com/licenses"
|
|
||||||
"tailscale.com/net/netutil"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/util/cmpx"
|
"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{
|
var webCmd = &ffcli.Command{
|
||||||
Name: "web",
|
Name: "web",
|
||||||
ShortUsage: "web [flags]",
|
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)
|
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webHandler := http.HandlerFunc(web.Handle)
|
||||||
|
|
||||||
if webArgs.cgi {
|
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)
|
log.Printf("tailscale.cgi: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -144,14 +91,14 @@ func runWeb(ctx context.Context, args []string) error {
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: webArgs.listen,
|
Addr: webArgs.listen,
|
||||||
TLSConfig: tlsConfig,
|
TLSConfig: tlsConfig,
|
||||||
Handler: http.HandlerFunc(webHandler),
|
Handler: webHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("web server running on: https://%s", server.Addr)
|
log.Printf("web server running on: https://%s", server.Addr)
|
||||||
return server.ListenAndServeTLS("", "")
|
return server.ListenAndServeTLS("", "")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen))
|
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)
|
host, port, _ := net.SplitHostPort(addr)
|
||||||
return fmt.Sprintf("http://%s", net.JoinHostPort(cmpx.Or(host, "127.0.0.1"), port))
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
|
||||||
"testing"
|
"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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -68,6 +68,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
|
||||||
tailscale.com/atomicfile from tailscale.com/ipn+
|
tailscale.com/atomicfile from tailscale.com/ipn+
|
||||||
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
|
tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+
|
||||||
tailscale.com/client/tailscale/apitype 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/cmd/tailscale/cli from tailscale.com/cmd/tailscale
|
||||||
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
|
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
|
||||||
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
|
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/hostinfo from tailscale.com/net/interfaces+
|
||||||
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
|
tailscale.com/ipn from tailscale.com/cmd/tailscale/cli+
|
||||||
tailscale.com/ipn/ipnstate 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/metrics from tailscale.com/derp
|
||||||
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
tailscale.com/net/dns/recursive from tailscale.com/net/dnsfallback
|
||||||
tailscale.com/net/dnscache from tailscale.com/derp/derphttp+
|
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+
|
tailscale.com/util/cmpx from tailscale.com/cmd/tailscale/cli+
|
||||||
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
|
||||||
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
|
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/httpm from tailscale.com/client/tailscale
|
||||||
tailscale.com/util/lineread from tailscale.com/net/interfaces+
|
tailscale.com/util/lineread from tailscale.com/net/interfaces+
|
||||||
L tailscale.com/util/linuxfw from tailscale.com/net/netns
|
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/hex from crypto/x509+
|
||||||
encoding/json from expvar+
|
encoding/json from expvar+
|
||||||
encoding/pem from crypto/tls+
|
encoding/pem from crypto/tls+
|
||||||
encoding/xml from tailscale.com/cmd/tailscale/cli+
|
encoding/xml from github.com/tailscale/goupnp+
|
||||||
errors from bufio+
|
errors from bufio+
|
||||||
expvar from tailscale.com/derp+
|
expvar from tailscale.com/derp+
|
||||||
flag from github.com/peterbourgon/ff/v3+
|
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/crc32 from compress/gzip+
|
||||||
hash/maphash from go4.org/mem
|
hash/maphash from go4.org/mem
|
||||||
html from tailscale.com/ipn/ipnstate+
|
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 from github.com/skip2/go-qrcode+
|
||||||
image/color from github.com/skip2/go-qrcode+
|
image/color from github.com/skip2/go-qrcode+
|
||||||
image/png from github.com/skip2/go-qrcode
|
image/png from github.com/skip2/go-qrcode
|
||||||
|
|
Loading…
Reference in New Issue