diff --git a/cmd/tailscale/cli/auth-redirect.html b/cmd/tailscale/cli/auth-redirect.html
new file mode 100644
index 000000000..559d8fb4f
--- /dev/null
+++ b/cmd/tailscale/cli/auth-redirect.html
@@ -0,0 +1,57 @@
+
+
+ Redirecting...
+
+
+
+ Redirecting...
+
\ No newline at end of file
diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go
index 99944ce3b..df0085ad7 100644
--- a/cmd/tailscale/cli/web.go
+++ b/cmd/tailscale/cli/web.go
@@ -5,28 +5,29 @@
package cli
import (
- "bufio"
"bytes"
"context"
_ "embed"
"encoding/json"
+ "encoding/xml"
"flag"
"fmt"
"html/template"
+ "io/ioutil"
"log"
"net/http"
"net/http/cgi"
- "os"
+ "net/url"
"os/exec"
"runtime"
"strings"
"github.com/peterbourgon/ff/v2/ffcli"
- "go4.org/mem"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/types/preftype"
+ "tailscale.com/util/groupmember"
"tailscale.com/version/distro"
)
@@ -36,6 +37,9 @@ var webHTML string
//go:embed web.css
var webCSS string
+//go:embed auth-redirect.html
+var authenticationRedirectHTML string
+
var tmpl *template.Template
func init() {
@@ -85,57 +89,98 @@ func runWeb(ctx context.Context, args []string) error {
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
}
-// authorize checks whether the provided user has access to the web UI.
-func authorize(name string) error {
- if distro.Get() == distro.Synology {
- return authorizeSynology(name)
+// 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
+ 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 {
- f, err := os.Open("/etc/group")
+ yes, err := groupmember.IsMemberOfGroup("administrators", name)
if err != nil {
return err
}
- defer f.Close()
- s := bufio.NewScanner(f)
- var agLine string
- for s.Scan() {
- if !mem.HasPrefix(mem.B(s.Bytes()), mem.S("administrators:")) {
- continue
- }
- agLine = s.Text()
- break
+ if !yes {
+ return fmt.Errorf("not a member of administrators group")
}
- if err := s.Err(); err != nil {
- return err
- }
- if agLine == "" {
- return fmt.Errorf("admin group not defined")
- }
- agEntry := strings.Split(agLine, ":")
- if len(agEntry) < 4 {
- return fmt.Errorf("malformed admin group entry")
- }
- agMembers := agEntry[3]
- for _, m := range strings.Split(agMembers, ",") {
- if m == name {
- return nil
- }
- }
- return fmt.Errorf("not a member of administrators group")
+ return nil
}
-// authenticate returns the name of the user accessing the web UI.
-// Note: This is different from a tailscale user, and is typically the local
-// user on the node.
-func authenticate() (string, error) {
- if distro.Get() != distro.Synology {
- 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 "", nil, err
+ }
+ query := url.Values{
+ "qtoken": []string{token.Value},
+ "user": []string{user.Value},
+ }
+ u := url.URL{
+ Scheme: r.URL.Scheme,
+ Host: r.URL.Host,
+ Path: "/cgi-bin/authLogin.cgi",
+ RawQuery: query.Encode(),
+ }
+ resp, err := http.Get(u.String())
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+ out, err := ioutil.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.Value, authResp, nil
+}
+
+func synoAuthn() (string, error) {
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput()
if err != nil {
@@ -144,10 +189,14 @@ func authenticate() (string, error) {
return strings.TrimSpace(string(out)), nil
}
-func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
- if distro.Get() != distro.Synology {
- return false
+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
}
@@ -181,80 +230,13 @@ req.send(null);