Update dependencies
This commit is contained in:
131
vendor/tailscale.com/client/web/assets.go
generated
vendored
Normal file
131
vendor/tailscale.com/client/web/assets.go
generated
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
prebuilt "github.com/tailscale/web-client-prebuilt"
|
||||
)
|
||||
|
||||
var start = time.Now()
|
||||
|
||||
func assetsHandler(devMode bool) (_ http.Handler, cleanup func()) {
|
||||
if devMode {
|
||||
// When in dev mode, proxy asset requests to the Vite dev server.
|
||||
cleanup := startDevServer()
|
||||
return devServerProxy(), cleanup
|
||||
}
|
||||
|
||||
fsys := prebuilt.FS()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
f, err := openPrecompressedFile(w, r, path, fsys)
|
||||
if err != nil {
|
||||
// Rewrite request to just fetch index.html and let
|
||||
// the frontend router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
path = "index.html"
|
||||
f, err = openPrecompressedFile(w, r, path, fsys)
|
||||
}
|
||||
if f == nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// fs.File does not claim to implement Seeker, but in practice it does.
|
||||
fSeeker, ok := f.(io.ReadSeeker)
|
||||
if !ok {
|
||||
http.Error(w, "Not seekable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "assets/") {
|
||||
// Aggressively cache static assets, since we cache-bust our assets with
|
||||
// hashed filenames.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31535996")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, path, start, fSeeker)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func openPrecompressedFile(w http.ResponseWriter, r *http.Request, path string, fs fs.FS) (fs.File, error) {
|
||||
if f, err := fs.Open(path + ".gz"); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
return f, nil
|
||||
}
|
||||
return fs.Open(path) // fallback
|
||||
}
|
||||
|
||||
// startDevServer starts the JS dev server that does on-demand rebuilding
|
||||
// and serving of web client JS and CSS resources.
|
||||
func startDevServer() (cleanup func()) {
|
||||
root := gitRootDir()
|
||||
webClientPath := filepath.Join(root, "client", "web")
|
||||
|
||||
yarn := filepath.Join(root, "tool", "yarn")
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webClientPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s...", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webClientPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running tailscale web's yarn install: %v, %s", err, out)
|
||||
}
|
||||
log.Printf("starting JavaScript dev server...")
|
||||
cmd := exec.Command(node, vite)
|
||||
cmd.Dir = webClientPath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatalf("Starting JS dev server: %v", err)
|
||||
}
|
||||
log.Printf("JavaScript dev server running as pid %d", cmd.Process.Pid)
|
||||
return func() {
|
||||
cmd.Process.Signal(os.Interrupt)
|
||||
err := cmd.Wait()
|
||||
log.Printf("JavaScript dev server exited: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// devServerProxy returns a reverse proxy to the vite dev server.
|
||||
func devServerProxy() *httputil.ReverseProxy {
|
||||
// We use Vite to develop on the web client.
|
||||
// Vite starts up its own local server for development,
|
||||
// which we proxy requests to from Server.ServeHTTP.
|
||||
// Here we set up the proxy to Vite's server.
|
||||
handleErr := func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte("The web client development server isn't running. " +
|
||||
"Run `./tool/yarn --cwd client/web start` from " +
|
||||
"the repo root to start the development server."))
|
||||
w.Write([]byte("\n\nError: " + err.Error()))
|
||||
}
|
||||
viteTarget, _ := url.Parse("http://127.0.0.1:4000")
|
||||
devProxy := httputil.NewSingleHostReverseProxy(viteTarget)
|
||||
devProxy.ErrorHandler = handleErr
|
||||
return devProxy
|
||||
}
|
||||
|
||||
func gitRootDir() string {
|
||||
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to find git top level (not in corp git?): %v", err)
|
||||
}
|
||||
return strings.TrimSpace(string(top))
|
||||
}
|
||||
339
vendor/tailscale.com/client/web/auth.go
generated
vendored
Normal file
339
vendor/tailscale.com/client/web/auth.go
generated
vendored
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "TS-Web-Session"
|
||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
||||
)
|
||||
|
||||
// browserSession holds data about a user's browser session
|
||||
// on the full management web client.
|
||||
type browserSession struct {
|
||||
// ID is the unique identifier for the session.
|
||||
// It is passed in the user's "TS-Web-Session" browser cookie.
|
||||
ID string
|
||||
SrcNode tailcfg.NodeID
|
||||
SrcUser tailcfg.UserID
|
||||
AuthID string // from tailcfg.WebClientAuthResponse
|
||||
AuthURL string // from tailcfg.WebClientAuthResponse
|
||||
Created time.Time
|
||||
Authenticated bool
|
||||
}
|
||||
|
||||
// isAuthorized reports true if the given session is authorized
|
||||
// to be used by its associated user to access the full management
|
||||
// web client.
|
||||
//
|
||||
// isAuthorized is true only when s.Authenticated is true (i.e.
|
||||
// the user has authenticated the session) and the session is not
|
||||
// expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isAuthorized(now time.Time) bool {
|
||||
switch {
|
||||
case s == nil:
|
||||
return false
|
||||
case !s.Authenticated:
|
||||
return false // awaiting auth
|
||||
case s.isExpired(now):
|
||||
return false // expired
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isExpired reports true if s is expired.
|
||||
// 2023-10-05: Sessions expire by default 30 days after creation.
|
||||
func (s *browserSession) isExpired(now time.Time) bool {
|
||||
return !s.Created.IsZero() && now.After(s.expires())
|
||||
}
|
||||
|
||||
// expires reports when the given session expires.
|
||||
func (s *browserSession) expires() time.Time {
|
||||
return s.Created.Add(sessionCookieExpiry)
|
||||
}
|
||||
|
||||
var (
|
||||
errNoSession = errors.New("no-browser-session")
|
||||
errNotUsingTailscale = errors.New("not-using-tailscale")
|
||||
errTaggedRemoteSource = errors.New("tagged-remote-source")
|
||||
errTaggedLocalSource = errors.New("tagged-local-source")
|
||||
errNotOwner = errors.New("not-owner")
|
||||
)
|
||||
|
||||
// getSession retrieves the browser session associated with the request,
|
||||
// if one exists.
|
||||
//
|
||||
// An error is returned in any of the following cases:
|
||||
//
|
||||
// - (errNotUsingTailscale) The request was not made over tailscale.
|
||||
//
|
||||
// - (errNoSession) The request does not have a session.
|
||||
//
|
||||
// - (errTaggedRemoteSource) The source is remote (another node) and tagged.
|
||||
// Users must use their own user-owned devices to manage other nodes'
|
||||
// web clients.
|
||||
//
|
||||
// - (errTaggedLocalSource) The source is local (the same node) and tagged.
|
||||
// Tagged nodes can only be remotely managed, allowing ACLs to dictate
|
||||
// access to web clients.
|
||||
//
|
||||
// - (errNotOwner) The source is not the owner of this client (if the
|
||||
// client is user-owned). Only the owner is allowed to manage the
|
||||
// node via the web client.
|
||||
//
|
||||
// If no error is returned, the browserSession is always non-nil.
|
||||
// getTailscaleBrowserSession does not check whether the session has been
|
||||
// authorized by the user. Callers can use browserSession.isAuthorized.
|
||||
//
|
||||
// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
|
||||
// unless getTailscaleBrowserSession reports errNotUsingTailscale.
|
||||
func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
|
||||
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
||||
switch {
|
||||
case whoIsErr != nil:
|
||||
return nil, nil, status, errNotUsingTailscale
|
||||
case statusErr != nil:
|
||||
return nil, whoIs, nil, statusErr
|
||||
case status.Self == nil:
|
||||
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
||||
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
||||
return nil, whoIs, status, errTaggedLocalSource
|
||||
case whoIs.Node.IsTagged():
|
||||
return nil, whoIs, status, errTaggedRemoteSource
|
||||
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
||||
return nil, whoIs, status, errNotOwner
|
||||
}
|
||||
srcNode := whoIs.Node.ID
|
||||
srcUser := whoIs.UserProfile.ID
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if err != nil {
|
||||
return nil, whoIs, status, err
|
||||
}
|
||||
v, ok := s.browserSessions.Load(cookie.Value)
|
||||
if !ok {
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
session := v.(*browserSession)
|
||||
if session.SrcNode != srcNode || session.SrcUser != srcUser {
|
||||
// In this case the browser cookie is associated with another tailscale node.
|
||||
// Maybe the source browser's machine was logged out and then back in as a different node.
|
||||
// Return errNoSession because there is no session for this user.
|
||||
return nil, whoIs, status, errNoSession
|
||||
} else if session.isExpired(s.timeNow()) {
|
||||
// Session expired, remove from session map and return errNoSession.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return nil, whoIs, status, errNoSession
|
||||
}
|
||||
return session, whoIs, status, nil
|
||||
}
|
||||
|
||||
// newSession creates a new session associated with the given source user/node,
|
||||
// and stores it back to the session cache. Creating of a new session includes
|
||||
// generating a new auth URL from the control server.
|
||||
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
|
||||
sid, err := s.newSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session := &browserSession{
|
||||
ID: sid,
|
||||
SrcNode: src.Node.ID,
|
||||
SrcUser: src.UserProfile.ID,
|
||||
Created: s.timeNow(),
|
||||
}
|
||||
|
||||
if s.controlSupportsCheckMode(ctx) {
|
||||
// control supports check mode, so get a new auth URL and return.
|
||||
a, err := s.newAuthURL(ctx, src.Node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.AuthID = a.ID
|
||||
session.AuthURL = a.URL
|
||||
} else {
|
||||
// control does not support check mode, so there is no additional auth we can do.
|
||||
session.Authenticated = true
|
||||
}
|
||||
|
||||
s.browserSessions.Store(sid, session)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
|
||||
// We assume that only "tailscale.com" control servers support check mode.
|
||||
// This allows the web client to be used with non-standard control servers.
|
||||
// If an error occurs getting the control URL, this method returns true to fail closed.
|
||||
//
|
||||
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
|
||||
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
||||
prefs, err := s.lc.GetPrefs(ctx)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
controlURL, err := url.Parse(prefs.ControlURLOrDefault())
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
||||
}
|
||||
|
||||
// awaitUserAuth blocks until the given session auth has been completed
|
||||
// by the user on the control server, then updates the session cache upon
|
||||
// completion. An error is returned if control auth failed for any reason.
|
||||
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
|
||||
if session.isAuthorized(s.timeNow()) {
|
||||
return nil // already authorized
|
||||
}
|
||||
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
|
||||
if err != nil {
|
||||
// Clean up the session. Doing this on any error from control
|
||||
// server to avoid the user getting stuck with a bad session
|
||||
// cookie.
|
||||
s.browserSessions.Delete(session.ID)
|
||||
return err
|
||||
}
|
||||
if a.Complete {
|
||||
session.Authenticated = a.Complete
|
||||
s.browserSessions.Store(session.ID, session)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) newSessionID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
for range 5 {
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
if _, ok := s.browserSessions.Load(cookie); !ok {
|
||||
return cookie, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("too many collisions generating new session; please refresh page")
|
||||
}
|
||||
|
||||
// peerCapabilities holds information about what a source
|
||||
// peer is allowed to edit via the web UI.
|
||||
//
|
||||
// map value is true if the peer can edit the given feature.
|
||||
// Only capFeatures included in validCaps will be included.
|
||||
type peerCapabilities map[capFeature]bool
|
||||
|
||||
// canEdit is true if the peerCapabilities grant edit access
|
||||
// to the given feature.
|
||||
func (p peerCapabilities) canEdit(feature capFeature) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
if p[capFeatureAll] {
|
||||
return true
|
||||
}
|
||||
return p[feature]
|
||||
}
|
||||
|
||||
// isEmpty is true if p is either nil or has no capabilities
|
||||
// with value true.
|
||||
func (p peerCapabilities) isEmpty() bool {
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
for _, v := range p {
|
||||
if v == true {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type capFeature string
|
||||
|
||||
const (
|
||||
// The following values should not be edited.
|
||||
// New caps can be added, but existing ones should not be changed,
|
||||
// as these exact values are used by users in tailnet policy files.
|
||||
//
|
||||
// IMPORTANT: When adding a new cap, also update validCaps slice below.
|
||||
|
||||
capFeatureAll capFeature = "*" // grants peer management of all features
|
||||
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
||||
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
|
||||
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
|
||||
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
||||
)
|
||||
|
||||
// validCaps contains the list of valid capabilities used in the web client.
|
||||
// Any capabilities included in a peer's grants that do not fall into this
|
||||
// list will be ignored.
|
||||
var validCaps []capFeature = []capFeature{
|
||||
capFeatureAll,
|
||||
capFeatureSSH,
|
||||
capFeatureSubnets,
|
||||
capFeatureExitNodes,
|
||||
capFeatureAccount,
|
||||
}
|
||||
|
||||
type capRule struct {
|
||||
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
|
||||
}
|
||||
|
||||
// toPeerCapabilities parses out the web ui capabilities from the
|
||||
// given whois response.
|
||||
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
|
||||
if whois == nil || status == nil {
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
if whois.Node.IsTagged() {
|
||||
// We don't allow management *from* tagged nodes, so ignore caps.
|
||||
// The web client auth flow relies on having a true user identity
|
||||
// that can be verified through login.
|
||||
return peerCapabilities{}, nil
|
||||
}
|
||||
|
||||
if !status.Self.IsTagged() {
|
||||
// User owned nodes are only ever manageable by the owner.
|
||||
if status.Self.UserID != whois.UserProfile.ID {
|
||||
return peerCapabilities{}, nil
|
||||
} else {
|
||||
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
|
||||
}
|
||||
}
|
||||
|
||||
// For tagged nodes, we actually look at the granted capabilities.
|
||||
caps := peerCapabilities{}
|
||||
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
|
||||
}
|
||||
for _, c := range rules {
|
||||
for _, f := range c.CanEdit {
|
||||
cap := capFeature(strings.ToLower(f))
|
||||
if slices.Contains(validCaps, cap) {
|
||||
caps[cap] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
28
vendor/tailscale.com/client/web/index.html
generated
vendored
Normal file
28
vendor/tailscale.com/client/web/index.html
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html class="bg-gray-50">
|
||||
<head>
|
||||
<title>Tailscale</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="shortcut icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
|
||||
<link rel="stylesheet" type="text/css" href="/src/index.css" />
|
||||
<link rel="preload" as="font" href="/src/assets/fonts/Inter.var.latin.woff2" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body class="px-2">
|
||||
<noscript>
|
||||
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p>
|
||||
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
|
||||
</noscript>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script>
|
||||
// if this script is changed, also change hash in web.go
|
||||
window.addEventListener("load", () => {
|
||||
if (!window.Tailscale) {
|
||||
const rootEl = document.createElement("p")
|
||||
rootEl.innerHTML = 'Tailscale web interface is unavailable.';
|
||||
document.body.append(rootEl)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
vendor/tailscale.com/client/web/package.json
generated
vendored
Normal file
81
vendor/tailscale.com/client/web/package.json
generated
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "webclient",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.20.4",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"swr": "^2.2.4",
|
||||
"wouter": "^2.11.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.16.1",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.23.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-curly-quotes": "^1.0.4",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vitest": "^1.3.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit && eslint 'src/**/*.{ts,tsx,js,jsx}'",
|
||||
"test": "vitest",
|
||||
"format": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||
"format-check": "prettier --check 'src/**/*.{ts,tsx}'"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
],
|
||||
"plugins": [
|
||||
"curly-quotes",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"curly-quotes/no-straight-quotes": "warn",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error"
|
||||
},
|
||||
"settings": {
|
||||
"projectRoot": "client/web/package.json"
|
||||
}
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
"autoprefixer": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
vendor/tailscale.com/client/web/qnap.go
generated
vendored
Normal file
127
vendor/tailscale.com/client/web/qnap.go
generated
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
// 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
|
||||
// are authorized to use the web client.
|
||||
// If the user is not authorized to use the client, an error is returned.
|
||||
func authorizeQNAP(r *http.Request) (authorized bool, err error) {
|
||||
_, resp, err := qnapAuthn(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.IsAdmin == 0 {
|
||||
return false, errors.New("user is not an admin")
|
||||
}
|
||||
|
||||
return true, 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
|
||||
}
|
||||
85
vendor/tailscale.com/client/web/styles.json
generated
vendored
Normal file
85
vendor/tailscale.com/client/web/styles.json
generated
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"colors": {
|
||||
"transparent": "transparent",
|
||||
"current": "currentColor",
|
||||
"white": "rgb(var(--color-white) / <alpha-value>)",
|
||||
"gray": {
|
||||
"0": "rgb(var(--color-gray-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-gray-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-gray-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-gray-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-gray-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-gray-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-gray-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-gray-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-gray-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-gray-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-gray-900) / <alpha-value>)"
|
||||
},
|
||||
"blue": {
|
||||
"0": "rgb(var(--color-blue-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-blue-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-blue-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-blue-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-blue-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-blue-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-blue-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-blue-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-blue-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-blue-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-blue-900) / <alpha-value>)"
|
||||
},
|
||||
"green": {
|
||||
"0": "rgb(var(--color-green-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-green-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-green-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-green-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-green-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-green-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-green-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-green-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-green-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-green-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-green-900) / <alpha-value>)"
|
||||
},
|
||||
"red": {
|
||||
"0": "rgb(var(--color-red-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-red-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-red-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-red-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-red-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-red-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-red-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-red-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-red-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-red-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-red-900) / <alpha-value>)"
|
||||
},
|
||||
"yellow": {
|
||||
"0": "rgb(var(--color-yellow-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-yellow-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-yellow-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-yellow-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-yellow-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-yellow-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-yellow-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-yellow-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-yellow-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-yellow-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-yellow-900) / <alpha-value>)"
|
||||
},
|
||||
"orange": {
|
||||
"0": "rgb(var(--color-orange-0) / <alpha-value>)",
|
||||
"50": "rgb(var(--color-orange-50) / <alpha-value>)",
|
||||
"100": "rgb(var(--color-orange-100) / <alpha-value>)",
|
||||
"200": "rgb(var(--color-orange-200) / <alpha-value>)",
|
||||
"300": "rgb(var(--color-orange-300) / <alpha-value>)",
|
||||
"400": "rgb(var(--color-orange-400) / <alpha-value>)",
|
||||
"500": "rgb(var(--color-orange-500) / <alpha-value>)",
|
||||
"600": "rgb(var(--color-orange-600) / <alpha-value>)",
|
||||
"700": "rgb(var(--color-orange-700) / <alpha-value>)",
|
||||
"800": "rgb(var(--color-orange-800) / <alpha-value>)",
|
||||
"900": "rgb(var(--color-orange-900) / <alpha-value>)"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
vendor/tailscale.com/client/web/synology.go
generated
vendored
Normal file
59
vendor/tailscale.com/client/web/synology.go
generated
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/util/groupmember"
|
||||
)
|
||||
|
||||
// authorizeSynology authenticates the logged-in Synology user and verifies
|
||||
// that they are authorized to use the web client.
|
||||
// If the user is authenticated, but not authorized to use the client, an error is returned.
|
||||
func authorizeSynology(r *http.Request) (authorized bool, err error) {
|
||||
if !hasSynoToken(r) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// authenticate the Synology user
|
||||
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: %v: %s", err, out)
|
||||
}
|
||||
user := strings.TrimSpace(string(out))
|
||||
|
||||
// check if the user is in the administrators group
|
||||
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !isAdmin {
|
||||
return false, errors.New("not a member of administrators group")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
|
||||
func hasSynoToken(r *http.Request) bool {
|
||||
if r.Header.Get("X-Syno-Token") != "" {
|
||||
return true
|
||||
}
|
||||
if r.URL.Query().Get("SynoToken") != "" {
|
||||
return true
|
||||
}
|
||||
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
117
vendor/tailscale.com/client/web/tailwind.config.js
generated
vendored
Normal file
117
vendor/tailscale.com/client/web/tailwind.config.js
generated
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
import plugin from "tailwindcss/plugin"
|
||||
import styles from "./styles.json"
|
||||
|
||||
const config = {
|
||||
theme: {
|
||||
screens: {
|
||||
sm: "420px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
mono: [
|
||||
"SFMono-Regular",
|
||||
"SFMono Regular",
|
||||
"Consolas",
|
||||
"Liberation Mono",
|
||||
"Menlo",
|
||||
"Courier",
|
||||
"monospace",
|
||||
],
|
||||
},
|
||||
fontWeight: {
|
||||
normal: "400",
|
||||
medium: "500",
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
},
|
||||
colors: styles.colors,
|
||||
extend: {
|
||||
colors: {
|
||||
...styles.colors,
|
||||
"bg-app": "var(--color-bg-app)",
|
||||
"bg-menu-item-hover": "var(--color-bg-menu-item-hover)",
|
||||
|
||||
"border-base": "var(--color-border-base)",
|
||||
|
||||
"text-base": "var(--color-text-base)",
|
||||
"text-muted": "var(--color-text-muted)",
|
||||
"text-disabled": "var(--color-text-disabled)",
|
||||
"text-primary": "var(--color-text-primary)",
|
||||
"text-warning": "var(--color-text-warning)",
|
||||
"text-danger": "var(--color-text-danger)",
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: "var(--color-border-base)",
|
||||
},
|
||||
boxShadow: {
|
||||
dialog: "0 10px 40px rgba(0,0,0,0.12), 0 0 16px rgba(0,0,0,0.08)",
|
||||
form: "0 1px 1px rgba(0, 0, 0, 0.04)",
|
||||
soft: "0 4px 12px 0 rgba(0, 0, 0, 0.03)",
|
||||
popover:
|
||||
"0 0 0 1px rgba(136, 152, 170, 0.1), 0 15px 35px 0 rgba(49, 49, 93, 0.1), 0 5px 15px 0 rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
animation: {
|
||||
"scale-in": "scale-in 120ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"scale-out": "scale-out 120ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
},
|
||||
transformOrigin: {
|
||||
"radix-hovercard": "var(--radix-hover-card-content-transform-origin)",
|
||||
"radix-popover": "var(--radix-popover-content-transform-origin)",
|
||||
"radix-tooltip": "var(--radix-tooltip-content-transform-origin)",
|
||||
},
|
||||
keyframes: {
|
||||
"scale-in": {
|
||||
"0%": {
|
||||
transform: "scale(0.94)",
|
||||
opacity: "0",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(1)",
|
||||
opacity: "1",
|
||||
},
|
||||
},
|
||||
"scale-out": {
|
||||
"0%": {
|
||||
transform: "scale(1)",
|
||||
opacity: "1",
|
||||
},
|
||||
"100%": {
|
||||
transform: "scale(0.94)",
|
||||
opacity: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant("state-open", [
|
||||
"&[data-state=“open”]",
|
||||
"[data-state=“open”] &",
|
||||
])
|
||||
addVariant("state-closed", [
|
||||
"&[data-state=“closed”]",
|
||||
"[data-state=“closed”] &",
|
||||
])
|
||||
addVariant("state-delayed-open", [
|
||||
"&[data-state=“delayed-open”]",
|
||||
"[data-state=“delayed-open”] &",
|
||||
])
|
||||
addVariant("state-active", ["&[data-state=“active”]"])
|
||||
addVariant("state-inactive", ["&[data-state=“inactive”]"])
|
||||
}),
|
||||
],
|
||||
content: ["./src/**/*.html", "./src/**/*.{ts,tsx}", "./index.html"],
|
||||
}
|
||||
|
||||
export default config
|
||||
18
vendor/tailscale.com/client/web/tsconfig.json
generated
vendored
Normal file
18
vendor/tailscale.com/client/web/tsconfig.json
generated
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ES2017",
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"types": ["vite-plugin-svgr/client", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
57
vendor/tailscale.com/client/web/vite.config.ts
generated
vendored
Normal file
57
vendor/tailscale.com/client/web/vite.config.ts
generated
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
/// <reference types="vitest" />
|
||||
import { createLogger, defineConfig } from "vite"
|
||||
import svgr from "vite-plugin-svgr"
|
||||
import paths from "vite-tsconfig-paths"
|
||||
|
||||
// Use a custom logger that filters out Vite's logging of server URLs, since
|
||||
// they are an attractive nuisance (we run a proxy in front of Vite, and the
|
||||
// tailscale web client should be accessed through that).
|
||||
// Unfortunately there's no option to disable this logging, so the best we can
|
||||
// do it to ignore calls from a specific function.
|
||||
const filteringLogger = createLogger(undefined, { allowClearScreen: false })
|
||||
const originalInfoLog = filteringLogger.info
|
||||
filteringLogger.info = (...args) => {
|
||||
if (new Error("ignored").stack?.includes("printServerUrls")) {
|
||||
return
|
||||
}
|
||||
originalInfoLog.apply(filteringLogger, args)
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [
|
||||
paths(),
|
||||
svgr(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: false,
|
||||
},
|
||||
esbuild: {
|
||||
logOverride: {
|
||||
// Silence a warning about `this` being undefined in ESM when at the
|
||||
// top-level. The way JSX is transpiled causes this to happen, but it
|
||||
// isn't a problem.
|
||||
// See: https://github.com/vitejs/vite/issues/8644
|
||||
"this-is-undefined-in-esm": "silent",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// This needs to be 127.0.0.1 instead of localhost, because of how our
|
||||
// Go proxy connects to it.
|
||||
host: "127.0.0.1",
|
||||
// If you change the port, be sure to update the proxy in assets.go too.
|
||||
port: 4000,
|
||||
},
|
||||
test: {
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
testTimeout: 20000,
|
||||
environment: "jsdom",
|
||||
deps: {
|
||||
inline: ["date-fns", /\.wasm\?url$/],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
customLogger: filteringLogger,
|
||||
})
|
||||
1347
vendor/tailscale.com/client/web/web.go
generated
vendored
Normal file
1347
vendor/tailscale.com/client/web/web.go
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
5434
vendor/tailscale.com/client/web/yarn.lock
generated
vendored
Normal file
5434
vendor/tailscale.com/client/web/yarn.lock
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user