cmd/tailscale/web: add support for QNAP

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2021-06-10 13:08:02 +05:00 committed by Maisem Ali
parent 8b11937eaf
commit f944614c5c
10 changed files with 332 additions and 155 deletions

View File

@ -0,0 +1,57 @@
<html>
<head>
<title>Redirecting...</title>
<style>
html,
body {
height: 100%;
}
html {
background-color: rgb(249, 247, 246);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spinner {
margin-bottom: 2rem;
border: 4px rgba(112, 110, 109, 0.5) solid;
border-left-color: transparent;
border-radius: 9999px;
width: 4rem;
height: 4rem;
-webkit-animation: spin 700ms linear infinite;
animation: spin 800ms linear infinite;
}
.label {
color: rgb(112, 110, 109);
padding-left: 0.4rem;
}
@-webkit-keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head> <body>
<div class="spinner"></div>
<div class="label">Redirecting...</div>
</body>

View File

@ -5,28 +5,29 @@
package cli package cli
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"encoding/xml"
"flag" "flag"
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"net/http/cgi" "net/http/cgi"
"os" "net/url"
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "strings"
"github.com/peterbourgon/ff/v2/ffcli" "github.com/peterbourgon/ff/v2/ffcli"
"go4.org/mem"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/preftype" "tailscale.com/types/preftype"
"tailscale.com/util/groupmember"
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
@ -36,6 +37,9 @@
//go:embed web.css //go:embed web.css
var webCSS string var webCSS string
//go:embed auth-redirect.html
var authenticationRedirectHTML string
var tmpl *template.Template var tmpl *template.Template
func init() { func init() {
@ -85,57 +89,98 @@ func runWeb(ctx context.Context, args []string) error {
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler)) return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
} }
// authorize checks whether the provided user has access to the web UI. // authorize returns the name of the user accessing the web UI after verifying
func authorize(name string) error { // whether the user has access to the web UI. The function will write the
if distro.Get() == distro.Synology { // error to the provided http.ResponseWriter.
return authorizeSynology(name) // 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
} }
return nil 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 // authorizeSynology checks whether the provided user has access to the web UI
// by consulting the membership of the "administrators" group. // by consulting the membership of the "administrators" group.
func authorizeSynology(name string) error { func authorizeSynology(name string) error {
f, err := os.Open("/etc/group") yes, err := groupmember.IsMemberOfGroup("administrators", name)
if err != nil { if err != nil {
return err return err
} }
defer f.Close() if !yes {
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 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 fmt.Errorf("not a member of administrators group")
}
return nil
} }
// authenticate returns the name of the user accessing the web UI. type qnapAuthResponse struct {
// Note: This is different from a tailscale user, and is typically the local AuthPassed int `xml:"authPassed"`
// user on the node. IsAdmin int `xml:"isAdmin"`
func authenticate() (string, error) { AuthSID string `xml:"authSid"`
if distro.Get() != distro.Synology { ErrorValue int `xml:"errorValue"`
return "", nil }
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") cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -144,10 +189,14 @@ func authenticate() (string, error) {
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { func authRedirect(w http.ResponseWriter, r *http.Request) bool {
if distro.Get() != distro.Synology { if distro.Get() == distro.Synology {
return false return synoTokenRedirect(w, r)
} }
return false
}
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get("X-Syno-Token") != "" { if r.Header.Get("X-Syno-Token") != "" {
return false return false
} }
@ -181,80 +230,13 @@ func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
</body></html> </body></html>
` `
const authenticationRedirectHTML = `
<html>
<head>
<title>Redirecting...</title>
<style>
html,
body {
height: 100%;
}
html {
background-color: rgb(249, 247, 246);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.spinner {
margin-bottom: 2rem;
border: 4px rgba(112, 110, 109, 0.5) solid;
border-left-color: transparent;
border-radius: 9999px;
width: 4rem;
height: 4rem;
-webkit-animation: spin 700ms linear infinite;
animation: spin 800ms linear infinite;
}
.label {
color: rgb(112, 110, 109);
padding-left: 0.4rem;
}
@-webkit-keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="spinner"></div>
<div class="label">Redirecting...</div>
</body>
`
func webHandler(w http.ResponseWriter, r *http.Request) { func webHandler(w http.ResponseWriter, r *http.Request) {
if synoTokenRedirect(w, r) { if authRedirect(w, r) {
return return
} }
user, err := authenticate() user, err := authorize(w, r)
if err != nil { 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 return
} }
@ -268,7 +250,7 @@ type mi map[string]interface{
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
url, err := tailscaleUpForceReauth(r.Context()) url, err := tailscaleUpForceReauth(r.Context())
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()}) json.NewEncoder(w).Encode(mi{"error": err.Error()})
return return
} }
@ -278,7 +260,7 @@ type mi map[string]interface{
st, err := tailscale.Status(r.Context()) st, err := tailscale.Status(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -296,7 +278,7 @@ type mi map[string]interface{
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
if err := tmpl.Execute(buf, data); err != nil { if err := tmpl.Execute(buf, data); err != nil {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
w.Write(buf.Bytes()) w.Write(buf.Bytes())

View File

@ -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/types/wgkey from tailscale.com/types/netmap+
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
W tailscale.com/util/endian from tailscale.com/net/netns 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 L tailscale.com/util/lineread from tailscale.com/net/interfaces
tailscale.com/version from tailscale.com/cmd/tailscale/cli+ tailscale.com/version from tailscale.com/cmd/tailscale/cli+
tailscale.com/version/distro 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/macho from rsc.io/goversion/version
debug/pe from rsc.io/goversion/version debug/pe from rsc.io/goversion/version
embed from tailscale.com/cmd/tailscale/cli embed from tailscale.com/cmd/tailscale/cli
encoding from encoding/json encoding from encoding/json+
encoding/asn1 from crypto/x509+ encoding/asn1 from crypto/x509+
encoding/base64 from encoding/json+ encoding/base64 from encoding/json+
encoding/binary from compress/gzip+ encoding/binary from compress/gzip+
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
errors from bufio+ errors from bufio+
expvar from tailscale.com/derp+ expvar from tailscale.com/derp+
flag from github.com/peterbourgon/ff/v2+ 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 from crypto/rand+
os/exec from github.com/toqueteos/webbrowser+ os/exec from github.com/toqueteos/webbrowser+
os/signal from tailscale.com/cmd/tailscale/cli os/signal from tailscale.com/cmd/tailscale/cli
os/user from tailscale.com/util/groupmember
path from debug/dwarf+ path from debug/dwarf+
path/filepath from crypto/x509+ path/filepath from crypto/x509+
reflect from crypto/x509+ reflect from crypto/x509+

View File

@ -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 L tailscale.com/util/cmpver from tailscale.com/net/dns
tailscale.com/util/dnsname from tailscale.com/ipn/ipnstate+ tailscale.com/util/dnsname from tailscale.com/ipn/ipnstate+
LW tailscale.com/util/endian from tailscale.com/net/netns+ 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+ L tailscale.com/util/lineread from tailscale.com/control/controlclient+
tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+ tailscale.com/util/osshare from tailscale.com/cmd/tailscaled+
tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver

View File

@ -24,7 +24,6 @@
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@ -41,9 +40,11 @@
"tailscale.com/safesocket" "tailscale.com/safesocket"
"tailscale.com/smallzstd" "tailscale.com/smallzstd"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/util/groupmember"
"tailscale.com/util/pidowner" "tailscale.com/util/pidowner"
"tailscale.com/util/systemd" "tailscale.com/util/systemd"
"tailscale.com/version" "tailscale.com/version"
"tailscale.com/version/distro"
"tailscale.com/wgengine" "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) logf("connection from userid %v; is configured operator", uid)
return rw return rw
} }
var adminGroupID string if yes, err := isLocalAdmin(uid); err != nil {
switch runtime.GOOS { logf("connection from userid %v; read-only; %v", uid, err)
case "darwin":
adminGroupID = darwinAdminGroupID()
default:
logf("connection from userid %v; read-only", uid)
return ro return ro
} } else if yes {
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) logf("connection from userid %v; is local admin, has access", uid)
return rw return rw
} }
}
logf("connection from userid %v; read-only", uid) logf("connection from userid %v; read-only", uid)
return ro return ro
} }
var darwinAdminGroupIDCache atomic.Value // of string func isLocalAdmin(uid string) (bool, error) {
u, err := user.LookupId(uid)
func darwinAdminGroupID() string {
s, _ := darwinAdminGroupIDCache.Load().(string)
if s != "" {
return s
}
g, err := user.LookupGroup("admin")
if err != nil { if err != nil {
return "" return false, err
} }
darwinAdminGroupIDCache.Store(g.Gid) var adminGroup string
return g.Gid 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 // inUseOtherUserError is the error type for when the server is in use

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -18,6 +18,7 @@
Synology = Distro("synology") Synology = Distro("synology")
OpenWrt = Distro("openwrt") OpenWrt = Distro("openwrt")
NixOS = Distro("nixos") NixOS = Distro("nixos")
QNAP = Distro("qnap")
) )
// Get returns the current distro, or the empty string if unknown. // Get returns the current distro, or the empty string if unknown.
@ -50,6 +51,8 @@ func linuxDistro() Distro {
return OpenWrt return OpenWrt
case have("/run/current-system/sw/bin/nixos-version"): case have("/run/current-system/sw/bin/nixos-version"):
return NixOS return NixOS
case have("/etc/config/uLinux.conf"):
return QNAP
} }
return "" return ""
} }