cmd/tailscale/web: add support for QNAP

Signed-off-by: Maisem Ali <maisem@tailscale.com>
pull/2108/head
Maisem Ali 3 years ago committed by Maisem Ali
parent 8b11937eaf
commit f944614c5c

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

@ -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
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
}
if agLine == "" {
return fmt.Errorf("admin group not defined")
token, err := r.Cookie("qtoken")
if err != nil {
return "", nil, err
}
agEntry := strings.Split(agLine, ":")
if len(agEntry) < 4 {
return fmt.Errorf("malformed admin group entry")
query := url.Values{
"qtoken": []string{token.Value},
"user": []string{user.Value},
}
agMembers := agEntry[3]
for _, m := range strings.Split(agMembers, ",") {
if m == name {
return nil
}
u := url.URL{
Scheme: r.URL.Scheme,
Host: r.URL.Host,
Path: "/cgi-bin/authLogin.cgi",
RawQuery: query.Encode(),
}
return fmt.Errorf("not a member of administrators group")
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
}
// 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
}
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);
</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) {
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 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
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 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
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 @@ func webHandler(w http.ResponseWriter, r *http.Request) {
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())

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

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

@ -24,7 +24,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@ -41,9 +40,11 @@ import (
"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)
return ro
}
if adminGroupID == "" {
logf("connection from userid %v; no system admin group found, read-only", uid)
if yes, err := isLocalAdmin(uid); err != nil {
logf("connection from userid %v; read-only; %v", uid, err)
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
}
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")
}
darwinAdminGroupIDCache.Store(g.Gid)
return g.Gid
return groupmember.IsMemberOfGroup(adminGroup, u.Username)
}
// inUseOtherUserError is the error type for when the server is in use

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

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

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

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

@ -18,6 +18,7 @@ const (
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 ""
}

Loading…
Cancel
Save