From f944614c5c0a6fa9ef917dada05e1ca3e4478ac0 Mon Sep 17 00:00:00 2001 From: Maisem Ali Date: Thu, 10 Jun 2021 13:08:02 +0500 Subject: [PATCH] cmd/tailscale/web: add support for QNAP Signed-off-by: Maisem Ali --- cmd/tailscale/cli/auth-redirect.html | 57 +++++++ cmd/tailscale/cli/web.go | 214 +++++++++++-------------- cmd/tailscale/depaware.txt | 5 +- cmd/tailscaled/depaware.txt | 1 + ipn/ipnserver/server.go | 58 +++---- util/groupmember/groupmember.go | 21 +++ util/groupmember/groupmember_cgo.go | 48 ++++++ util/groupmember/groupmember_noimpl.go | 9 ++ util/groupmember/groupmember_notcgo.go | 71 ++++++++ version/distro/distro.go | 3 + 10 files changed, 332 insertions(+), 155 deletions(-) create mode 100644 cmd/tailscale/cli/auth-redirect.html create mode 100644 util/groupmember/groupmember.go create mode 100644 util/groupmember/groupmember_cgo.go create mode 100644 util/groupmember/groupmember_noimpl.go create mode 100644 util/groupmember/groupmember_notcgo.go 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 @@ //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 @@ func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { ` -const authenticationRedirectHTML = ` - - - Redirecting... - - - -
-
Redirecting...
- -` - func webHandler(w http.ResponseWriter, r *http.Request) { - if synoTokenRedirect(w, r) { + if authRedirect(w, r) { return } - user, err := authenticate() + user, err := authorize(w, r) if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - if err := authorize(user); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) return } @@ -268,7 +250,7 @@ type mi map[string]interface{ w.Header().Set("Content-Type", "application/json") url, err := tailscaleUpForceReauth(r.Context()) if err != nil { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(mi{"error": err.Error()}) return } @@ -278,7 +260,7 @@ type mi map[string]interface{ st, err := tailscale.Status(r.Context()) if err != nil { - http.Error(w, err.Error(), 500) + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -296,7 +278,7 @@ type mi map[string]interface{ buf := new(bytes.Buffer) if err := tmpl.Execute(buf, data); err != nil { - http.Error(w, err.Error(), 500) + http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write(buf.Bytes()) diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 6620c8461..d74464198 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -53,6 +53,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/types/wgkey from tailscale.com/types/netmap+ tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ W tailscale.com/util/endian from tailscale.com/net/netns + tailscale.com/util/groupmember from tailscale.com/cmd/tailscale/cli L tailscale.com/util/lineread from tailscale.com/net/interfaces tailscale.com/version from tailscale.com/cmd/tailscale/cli+ tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+ @@ -118,13 +119,14 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep debug/macho from rsc.io/goversion/version debug/pe from rsc.io/goversion/version embed from tailscale.com/cmd/tailscale/cli - encoding from encoding/json + encoding from encoding/json+ encoding/asn1 from crypto/x509+ encoding/base64 from encoding/json+ encoding/binary from compress/gzip+ encoding/hex from crypto/x509+ encoding/json from expvar+ encoding/pem from crypto/tls+ + encoding/xml from tailscale.com/cmd/tailscale/cli errors from bufio+ expvar from tailscale.com/derp+ flag from github.com/peterbourgon/ff/v2+ @@ -156,6 +158,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep os from crypto/rand+ os/exec from github.com/toqueteos/webbrowser+ os/signal from tailscale.com/cmd/tailscale/cli + os/user from tailscale.com/util/groupmember path from debug/dwarf+ path/filepath from crypto/x509+ reflect from crypto/x509+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index ff7099465..6f6e3f1e4 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -135,6 +135,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L tailscale.com/util/cmpver from tailscale.com/net/dns tailscale.com/util/dnsname from tailscale.com/ipn/ipnstate+ LW tailscale.com/util/endian from tailscale.com/net/netns+ + tailscale.com/util/groupmember from tailscale.com/ipn/ipnserver L tailscale.com/util/lineread from tailscale.com/control/controlclient+ tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+ tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 7bba916c6..e67e9d8ac 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -24,7 +24,6 @@ "strconv" "strings" "sync" - "sync/atomic" "syscall" "time" @@ -41,9 +40,11 @@ "tailscale.com/safesocket" "tailscale.com/smallzstd" "tailscale.com/types/logger" + "tailscale.com/util/groupmember" "tailscale.com/util/pidowner" "tailscale.com/util/systemd" "tailscale.com/version" + "tailscale.com/version/distro" "tailscale.com/wgengine" ) @@ -347,51 +348,32 @@ func isReadonlyConn(ci connIdentity, operatorUID string, logf logger.Logf) bool logf("connection from userid %v; is configured operator", uid) return rw } - var adminGroupID string - switch runtime.GOOS { - case "darwin": - adminGroupID = darwinAdminGroupID() - default: - logf("connection from userid %v; read-only", uid) + if yes, err := isLocalAdmin(uid); err != nil { + logf("connection from userid %v; read-only; %v", uid, err) return ro - } - if adminGroupID == "" { - logf("connection from userid %v; no system admin group found, read-only", uid) - return ro - } - u, err := user.LookupId(uid) - if err != nil { - logf("connection from userid %v; failed to look up user; read-only", uid) - return ro - } - gids, err := u.GroupIds() - if err != nil { - logf("connection from userid %v; failed to look up groups; read-only", uid) - return ro - } - for _, gid := range gids { - if gid == adminGroupID { - logf("connection from userid %v; is local admin, has access", uid) - return rw - } + } else if yes { + logf("connection from userid %v; is local admin, has access", uid) + return rw } logf("connection from userid %v; read-only", uid) return ro } -var darwinAdminGroupIDCache atomic.Value // of string - -func darwinAdminGroupID() string { - s, _ := darwinAdminGroupIDCache.Load().(string) - if s != "" { - return s - } - g, err := user.LookupGroup("admin") +func isLocalAdmin(uid string) (bool, error) { + u, err := user.LookupId(uid) if err != nil { - return "" + return false, err } - darwinAdminGroupIDCache.Store(g.Gid) - return g.Gid + var adminGroup string + switch { + case runtime.GOOS == "darwin": + adminGroup = "admin" + case distro.Get() == distro.QNAP: + adminGroup = "administrators" + default: + return false, fmt.Errorf("no system admin group found") + } + return groupmember.IsMemberOfGroup(adminGroup, u.Username) } // inUseOtherUserError is the error type for when the server is in use diff --git a/util/groupmember/groupmember.go b/util/groupmember/groupmember.go new file mode 100644 index 000000000..49057e0d9 --- /dev/null +++ b/util/groupmember/groupmember.go @@ -0,0 +1,21 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package groupmemeber verifies group membership of the provided user on the +// local system. +package groupmember + +import ( + "errors" + "runtime" +) + +var ErrNotImplemented = errors.New("not implemented for GOOS=" + runtime.GOOS) + +// IsMemberOfGroup verifies if the provided user is member of the provided +// system group. +// If verfication fails, an error is returned. +func IsMemberOfGroup(group, userName string) (bool, error) { + return isMemberOfGroup(group, userName) +} diff --git a/util/groupmember/groupmember_cgo.go b/util/groupmember/groupmember_cgo.go new file mode 100644 index 000000000..2226ebf6e --- /dev/null +++ b/util/groupmember/groupmember_cgo.go @@ -0,0 +1,48 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build cgo + +package groupmember + +import ( + "os/user" + "sync" +) + +func isMemberOfGroup(group, name string) (bool, error) { + u, err := user.Lookup(name) + if err != nil { + return false, err + } + ugids, err := u.GroupIds() + if err != nil { + return false, err + } + gid, err := getGroupID(group) + if err != nil { + return false, err + } + for _, ugid := range ugids { + if gid == ugid { + return true, nil + } + } + return false, nil +} + +var groupIDCache sync.Map // of string + +func getGroupID(groupName string) (string, error) { + s, ok := groupIDCache.Load(groupName) + if ok { + return s.(string), nil + } + g, err := user.LookupGroup(groupName) + if err != nil { + return "", err + } + groupIDCache.Store(groupName, g.Gid) + return g.Gid, nil +} diff --git a/util/groupmember/groupmember_noimpl.go b/util/groupmember/groupmember_noimpl.go new file mode 100644 index 000000000..f6bf389b8 --- /dev/null +++ b/util/groupmember/groupmember_noimpl.go @@ -0,0 +1,9 @@ +// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build !cgo,!linux,!darwin + +package groupmember + +func isMemberOfGroup(group, name string) (bool, error) { return false, ErrNotImplemented } diff --git a/util/groupmember/groupmember_notcgo.go b/util/groupmember/groupmember_notcgo.go new file mode 100644 index 000000000..a8b8fcbe7 --- /dev/null +++ b/util/groupmember/groupmember_notcgo.go @@ -0,0 +1,71 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// +build !cgo +// +build linux darwin + +package groupmember + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + + "go4.org/mem" + "tailscale.com/version/distro" +) + +func isMemberOfGroup(group, name string) (bool, error) { + if distro.Get() == distro.Synology { + return isMemberOfGroupEtcGroup(group, name) + } + cmd := exec.Command("/usr/bin/env", "groups", name) + out, err := cmd.CombinedOutput() + if err != nil { + return false, err + } + groups := strings.Split(strings.TrimSpace(string(out)), " ") + for _, g := range groups { + if g == group { + return true, nil + } + } + return false, nil +} + +func isMemberOfGroupEtcGroup(group, name string) (bool, error) { + f, err := os.Open("/etc/group") + if err != nil { + return false, err + } + defer f.Close() + s := bufio.NewScanner(f) + var agLine string + for s.Scan() { + if !mem.HasPrefix(mem.B(s.Bytes()), mem.S(fmt.Sprintf("%s:", group))) { + continue + } + agLine = s.Text() + break + } + if err := s.Err(); err != nil { + return false, err + } + if agLine == "" { + return false, fmt.Errorf("admin group not defined") + } + agEntry := strings.Split(agLine, ":") + if len(agEntry) < 4 { + return false, fmt.Errorf("malformed admin group entry") + } + agMembers := agEntry[3] + for _, m := range strings.Split(agMembers, ",") { + if m == name { + return true, nil + } + } + return false, nil +} diff --git a/version/distro/distro.go b/version/distro/distro.go index 1271bd158..c873563d8 100644 --- a/version/distro/distro.go +++ b/version/distro/distro.go @@ -18,6 +18,7 @@ Synology = Distro("synology") OpenWrt = Distro("openwrt") NixOS = Distro("nixos") + QNAP = Distro("qnap") ) // Get returns the current distro, or the empty string if unknown. @@ -50,6 +51,8 @@ func linuxDistro() Distro { return OpenWrt case have("/run/current-system/sw/bin/nixos-version"): return NixOS + case have("/etc/config/uLinux.conf"): + return QNAP } return "" }