// 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 cli import ( "bufio" "bytes" "context" _ "embed" "encoding/json" "flag" "fmt" "html/template" "log" "net/http" "net/http/cgi" "os" "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/version/distro" ) //go:embed web.html var webHTML string //go:embed web.css var webCSS 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 } var webCmd = &ffcli.Command{ Name: "web", ShortUsage: "web [flags]", ShortHelp: "Run a web server for controlling Tailscale", FlagSet: (func() *flag.FlagSet { webf := flag.NewFlagSet("web", flag.ExitOnError) webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic") webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script") return webf })(), Exec: runWeb, } var webArgs struct { listen string cgi bool } func runWeb(ctx context.Context, args []string) error { if len(args) > 0 { log.Fatalf("too many non-flag arguments: %q", args) } if webArgs.cgi { if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil { log.Printf("tailscale.cgi: %v", err) return err } return nil } 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) } 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") 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 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") } // 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 } 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 synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { if distro.Get() != distro.Synology { return false } 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. serverURL := r.URL.Scheme + "://" + r.URL.Host fmt.Fprintf(w, synoTokenRedirectHTML, serverURL) return true } const synoTokenRedirectHTML = `
Redirecting with session token... ` const authenticationRedirectHTML = `