mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-18 20:51:45 +00:00
client/web: move synology and qnap logic into separate files
This commit doesn't change any of the logic, but just organizes the code a little to prepare for future changes. Updates tailscale/corp#13775 Signed-off-by: Will Norris <will@tailscale.com>
This commit is contained in:
parent
ff7f4b4224
commit
05486f0f8e
111
client/web/qnap.go
Normal file
111
client/web/qnap.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// qnap.go contains handlers and logic, such as authentication,
|
||||||
|
// that is specific to running the web client on QNAP.
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 qnapAuthnQtoken(r, user.Value, token.Value)
|
||||||
|
}
|
||||||
|
sid, err := r.Cookie("NAS_SID")
|
||||||
|
if err == nil {
|
||||||
|
return qnapAuthnSid(r, user.Value, sid.Value)
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
||||||
|
}
|
||||||
|
|
||||||
|
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
||||||
|
// running based on the request URL. This is necessary because QNAP has so
|
||||||
|
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
||||||
|
// and https://github.com/tailscale/tailscale/issues/6903
|
||||||
|
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
||||||
|
in, err := url.Parse(requestUrl)
|
||||||
|
scheme := ""
|
||||||
|
host := ""
|
||||||
|
if err != nil || in.Scheme == "" {
|
||||||
|
log.Printf("Cannot parse QNAP login URL %v", err)
|
||||||
|
|
||||||
|
// try localhost and hope for the best
|
||||||
|
scheme = "http"
|
||||||
|
host = "localhost"
|
||||||
|
} else {
|
||||||
|
scheme = in.Scheme
|
||||||
|
host = in.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: host,
|
||||||
|
Path: "/cgi-bin/authLogin.cgi",
|
||||||
|
RawQuery: query.Encode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
||||||
|
query := url.Values{
|
||||||
|
"qtoken": []string{token},
|
||||||
|
"user": []string{user},
|
||||||
|
}
|
||||||
|
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||||
|
}
|
||||||
|
|
||||||
|
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
||||||
|
query := url.Values{
|
||||||
|
"sid": []string{sid},
|
||||||
|
}
|
||||||
|
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
||||||
|
}
|
||||||
|
|
||||||
|
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
||||||
|
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
||||||
|
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
||||||
|
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: tr}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
out, err := io.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, authResp, nil
|
||||||
|
}
|
71
client/web/synology.go
Normal file
71
client/web/synology.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
// synology.go contains handlers and logic, such as authentication,
|
||||||
|
// that is specific to running the web client on Synology.
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"tailscale.com/util/groupmember"
|
||||||
|
)
|
||||||
|
|
||||||
|
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
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.
|
||||||
|
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const synoTokenRedirectHTML = `<html><body>
|
||||||
|
Redirecting with session token...
|
||||||
|
<script>
|
||||||
|
var serverURL = window.location.protocol + "//" + window.location.host;
|
||||||
|
var req = new XMLHttpRequest();
|
||||||
|
req.overrideMimeType("application/json");
|
||||||
|
req.open("GET", serverURL + "/webman/login.cgi", true);
|
||||||
|
req.onload = function() {
|
||||||
|
var jsonResponse = JSON.parse(req.responseText);
|
||||||
|
var token = jsonResponse["SynoToken"];
|
||||||
|
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
|
||||||
|
};
|
||||||
|
req.send(null);
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func synoAuthn() (string, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
yes, err := groupmember.IsMemberOfGroup("administrators", name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !yes {
|
||||||
|
return fmt.Errorf("not a member of administrators group")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
@ -8,10 +8,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/tls"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
@ -19,9 +17,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@ -33,7 +29,6 @@ import (
|
|||||||
"tailscale.com/licenses"
|
"tailscale.com/licenses"
|
||||||
"tailscale.com/net/netutil"
|
"tailscale.com/net/netutil"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/util/groupmember"
|
|
||||||
"tailscale.com/version/distro"
|
"tailscale.com/version/distro"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -138,122 +133,6 @@ func authorize(w http.ResponseWriter, r *http.Request) (string, error) {
|
|||||||
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 {
|
|
||||||
yes, err := groupmember.IsMemberOfGroup("administrators", name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !yes {
|
|
||||||
return fmt.Errorf("not a member of administrators group")
|
|
||||||
}
|
|
||||||
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 qnapAuthnQtoken(r, user.Value, token.Value)
|
|
||||||
}
|
|
||||||
sid, err := r.Cookie("NAS_SID")
|
|
||||||
if err == nil {
|
|
||||||
return qnapAuthnSid(r, user.Value, sid.Value)
|
|
||||||
}
|
|
||||||
return "", nil, fmt.Errorf("not authenticated by any mechanism")
|
|
||||||
}
|
|
||||||
|
|
||||||
// qnapAuthnURL returns the auth URL to use by inferring where the UI is
|
|
||||||
// running based on the request URL. This is necessary because QNAP has so
|
|
||||||
// many options, see https://github.com/tailscale/tailscale/issues/7108
|
|
||||||
// and https://github.com/tailscale/tailscale/issues/6903
|
|
||||||
func qnapAuthnURL(requestUrl string, query url.Values) string {
|
|
||||||
in, err := url.Parse(requestUrl)
|
|
||||||
scheme := ""
|
|
||||||
host := ""
|
|
||||||
if err != nil || in.Scheme == "" {
|
|
||||||
log.Printf("Cannot parse QNAP login URL %v", err)
|
|
||||||
|
|
||||||
// try localhost and hope for the best
|
|
||||||
scheme = "http"
|
|
||||||
host = "localhost"
|
|
||||||
} else {
|
|
||||||
scheme = in.Scheme
|
|
||||||
host = in.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
u := url.URL{
|
|
||||||
Scheme: scheme,
|
|
||||||
Host: host,
|
|
||||||
Path: "/cgi-bin/authLogin.cgi",
|
|
||||||
RawQuery: query.Encode(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func qnapAuthnQtoken(r *http.Request, user, token string) (string, *qnapAuthResponse, error) {
|
|
||||||
query := url.Values{
|
|
||||||
"qtoken": []string{token},
|
|
||||||
"user": []string{user},
|
|
||||||
}
|
|
||||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
|
||||||
}
|
|
||||||
|
|
||||||
func qnapAuthnSid(r *http.Request, user, sid string) (string, *qnapAuthResponse, error) {
|
|
||||||
query := url.Values{
|
|
||||||
"sid": []string{sid},
|
|
||||||
}
|
|
||||||
return qnapAuthnFinish(user, qnapAuthnURL(r.URL.String(), query))
|
|
||||||
}
|
|
||||||
|
|
||||||
func qnapAuthnFinish(user, url string) (string, *qnapAuthResponse, error) {
|
|
||||||
// QNAP Force HTTPS mode uses a self-signed certificate. Even importing
|
|
||||||
// the QNAP root CA isn't enough, the cert doesn't have a usable CN nor
|
|
||||||
// SAN. See https://github.com/tailscale/tailscale/issues/6903
|
|
||||||
tr := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
}
|
|
||||||
client := &http.Client{Transport: tr}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
out, err := io.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, authResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func synoAuthn() (string, error) {
|
|
||||||
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 authRedirect(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 synoTokenRedirect(w, r)
|
return synoTokenRedirect(w, r)
|
||||||
@ -261,39 +140,6 @@ func authRedirect(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
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.
|
|
||||||
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const synoTokenRedirectHTML = `<html><body>
|
|
||||||
Redirecting with session token...
|
|
||||||
<script>
|
|
||||||
var serverURL = window.location.protocol + "//" + window.location.host;
|
|
||||||
var req = new XMLHttpRequest();
|
|
||||||
req.overrideMimeType("application/json");
|
|
||||||
req.open("GET", serverURL + "/webman/login.cgi", true);
|
|
||||||
req.onload = function() {
|
|
||||||
var jsonResponse = JSON.parse(req.responseText);
|
|
||||||
var token = jsonResponse["SynoToken"];
|
|
||||||
document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
|
|
||||||
};
|
|
||||||
req.send(null);
|
|
||||||
</script>
|
|
||||||
</body></html>
|
|
||||||
`
|
|
||||||
|
|
||||||
// ServeHTTP processes all requests for the Tailscale web client.
|
// ServeHTTP processes all requests for the Tailscale web client.
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.devMode {
|
if s.devMode {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user