client/web: always use new web client; remove old client

This uses the new react-based web client for all builds, not just with
the --dev flag.

If the web client assets have not been built, the client will serve a
message that Tailscale was built without the web client, and link to
build instructions. Because we will include the web client in all of our
builds, this should only be seen by developers or users building from
source. (And eventually this will be replaced by attempting to download
needed assets as runtime.)

We do now checkin the build/index.html file, which serves the error
message when assets are unavailable.  This will also eventually be used
to trigger in CI when new assets should be built and uploaded to a
well-known location.

Updates tailscale/corp#13775

Signed-off-by: Will Norris <will@tailscale.com>
pull/9111/head
Will Norris 1 year ago committed by Will Norris
parent be5bd1e619
commit d74c771fda

2
.gitignore vendored

@ -38,7 +38,7 @@ cmd/tailscaled/tailscaled
# Ignore web client node modules # Ignore web client node modules
.vite/ .vite/
client/web/node_modules client/web/node_modules
client/web/build client/web/build/assets
/gocross /gocross
/dist /dist

@ -57,6 +57,17 @@ If your distro has conventions that preclude the use of
`build_dist.sh`, please do the equivalent of what it does in your `build_dist.sh`, please do the equivalent of what it does in your
distro's way, so that bug reports contain useful version information. distro's way, so that bug reports contain useful version information.
## Building the web client
To include the embedded web client (accessed via the `tailscale web` command),
you'll need to build the client assets using:
```
./tool/yarn --cwd client/web build
```
Do this before building the `tailscale.com/cmd/tailscale` binary.
## Bugs ## Bugs
Please file any issues about this code or the hosted service on Please file any issues about this code or the hosted service on

@ -5,6 +5,9 @@
# information into the binaries, so that we can track down user # information into the binaries, so that we can track down user
# issues. # issues.
# #
# To include the embedded web client, build the web client assets
# before running this script. See README.md for details.
#
# If you're packaging Tailscale for a distro, please consider using # If you're packaging Tailscale for a distro, please consider using
# this script, or executing equivalent commands in your # this script, or executing equivalent commands in your
# distro-specific build system. # distro-specific build system.

@ -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" />
<script type="module" crossorigin src="./assets/index-f8beba53.js"></script>
<link rel="stylesheet" href="./assets/index-8612dca6.css">
</head>
<body>
<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>
window.addEventListener("load", () => {
if (!window.Tailscale) {
const rootEl = document.createElement("p")
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
document.body.append(rootEl)
}
});
</script>
</body>
</html>

@ -8,22 +8,19 @@
<link rel="stylesheet" type="text/css" href="/src/index.css" /> <link rel="stylesheet" type="text/css" href="/src/index.css" />
</head> </head>
<body> <body>
<div class="min-h-screen py-10 flex justify-center items-center" style="display: none">
<div class="max-w-md">
<h3 class="font-semibold text-lg mb-4">Your web browser is unsupported.</h3>
<p class="mb-2">
Update to a modern browser to access the Tailscale web client. You can use
<a class="link" href="https://www.mozilla.org/en-US/firefox/new/" target="_blank">Firefox</a>,
<a class="link" href="https://www.microsoft.com/en-us/edge" target="_blank">Edge</a>,
<a class="link" href="https://www.apple.com/safari/" target="_blank">Safari</a>,
or <a class="link" href="https://www.google.com/chrome/" target="_blank">Chrome</a>.</p>
<p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a></p>
</div>
</div>
<noscript> <noscript>
<p class="mb-2">You need to enable Javascript to access the Tailscale web client.</p> <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> <p>If you need any help, feel free to <a href="mailto:support+webclient@tailscale.com" class="link">contact us</a>.</p>
</noscript> </noscript>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
<script>
window.addEventListener("load", () => {
if (!window.Tailscale) {
const rootEl = document.createElement("p")
rootEl.innerHTML = 'Tailscale was built without the web client. See <a href="https://github.com/tailscale/tailscale#building-the-web-client">Building the web client</a> for more information.'
document.body.append(rootEl)
}
});
</script>
</body> </body>
</html> </html>

@ -36,7 +36,16 @@ export default function useNodeData() {
const [isPosting, setIsPosting] = useState<boolean>(false) const [isPosting, setIsPosting] = useState<boolean>(false)
const fetchNodeData = useCallback(() => { const fetchNodeData = useCallback(() => {
apiFetch("api/data") const urlParams = new URLSearchParams(window.location.search)
const nextParams = new URLSearchParams()
const token = urlParams.get("SynoToken")
if (token) {
nextParams.set("SynoToken", token)
}
const search = nextParams.toString()
const url = `api/data${search ? `?${search}` : ""}`
apiFetch(url)
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setData(d)) .then((d) => setData(d))
.catch((error) => console.error(error)) .catch((error) => console.error(error))
@ -75,7 +84,7 @@ export default function useNodeData() {
nextParams.set("SynoToken", token) nextParams.set("SynoToken", token)
} }
const search = nextParams.toString() const search = nextParams.toString()
const url = `/api/data${search ? `?${search}` : ""}` const url = `api/data${search ? `?${search}` : ""}`
var body, contentType: string var body, contentType: string

@ -2,6 +2,10 @@ import React from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import App from "src/components/app" import App from "src/components/app"
declare var window: any
// This is used to determine if the react client is built.
window.Tailscale = true
const rootEl = document.createElement("div") const rootEl = document.createElement("div")
rootEl.id = "app-root" rootEl.id = "app-root"
rootEl.classList.add("relative", "z-0") rootEl.classList.add("relative", "z-0")

File diff suppressed because it is too large Load Diff

@ -5,14 +5,13 @@
package web package web
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template"
"io" "io"
"io/fs"
"log" "log"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
@ -31,6 +30,7 @@ import (
"tailscale.com/net/netutil" "tailscale.com/net/netutil"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/util/httpm" "tailscale.com/util/httpm"
"tailscale.com/util/must"
"tailscale.com/version/distro" "tailscale.com/version/distro"
) )
@ -38,15 +38,16 @@ import (
// Because we assign this to the blank identifier, it does not actually embed the files. // Because we assign this to the blank identifier, it does not actually embed the files.
// However, this does cause `go mod vendor` to include the files when vendoring the package. // However, this does cause `go mod vendor` to include the files when vendoring the package.
// External packages that use the web client can `go mod vendor`, run `yarn build` to // External packages that use the web client can `go mod vendor`, run `yarn build` to
// build the assets, then those asset bundles will be able to be embedded. // build the assets, then those asset bundles will be embedded.
// //
//go:embed yarn.lock index.html *.js *.json src/* //go:embed yarn.lock index.html *.js *.json src/*
var _ embed.FS var _ embed.FS
//go:embed web.html web.css //go:embed build/*
var embeddedFS embed.FS var embeddedFS embed.FS
var tmpls *template.Template // staticfiles serves static files from the build directory.
var staticfiles http.Handler
// Server is the backend server for a Tailscale web client. // Server is the backend server for a Tailscale web client.
type Server struct { type Server struct {
@ -103,14 +104,6 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
if s.devMode { if s.devMode {
cleanup = s.startDevServer() cleanup = s.startDevServer()
s.addProxyToDevServer() s.addProxyToDevServer()
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
// on network appliances that are served on local non-https URLs.
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
} }
var wg sync.WaitGroup var wg sync.WaitGroup
@ -121,12 +114,21 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
go s.watchSelf(ctx) go s.watchSelf(ctx)
}() }()
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
// on network appliances that are served on local non-https URLs.
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1) s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
return s, cleanup return s, cleanup
} }
func init() { func init() {
tmpls = template.Must(template.New("").ParseFS(embeddedFS, "*")) buildFiles := must.Get(fs.Sub(embeddedFS, "build"))
staticfiles = http.FileServer(http.FS(buildFiles))
} }
// watchSelf watches the IPN notification bus to refresh // watchSelf watches the IPN notification bus to refresh
@ -222,30 +224,23 @@ func authorize(w http.ResponseWriter, r *http.Request) (handled bool) {
} }
func (s *Server) serve(w http.ResponseWriter, r *http.Request) { func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
// Authenticate and authorize the request for platforms that support it. switch {
// Return if the request was processed. case authorize(w, r):
if authorize(w, r) { // Authenticate and authorize the request for platforms that support it.
// Return if the request was processed.
return return
} case strings.HasPrefix(r.URL.Path, "/api/"):
// Pass API requests through to the API handler.
if s.devMode { s.apiHandler.ServeHTTP(w, r)
if strings.HasPrefix(r.URL.Path, "/api/") {
// Pass through to other handlers via CSRF protection.
s.apiHandler.ServeHTTP(w, r)
return
}
// When in dev mode, proxy to the Vite dev server.
s.devProxy.ServeHTTP(w, r)
return return
} case s.devMode:
// When in dev mode, proxy non-api requests to the Vite dev server.
switch { s.devProxy.ServeHTTP(w, r)
case r.Method == "POST":
s.servePostNodeUpdate(w, r)
return return
default: default:
// Otherwise, serve static files from the embedded filesystem.
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1) s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
s.serveGetNodeData(w, r) staticfiles.ServeHTTP(w, r)
return return
} }
} }
@ -329,20 +324,6 @@ func (s *Server) getNodeData(ctx context.Context) (*nodeData, error) {
return data, nil return data, nil
} }
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
data, err := s.getNodeData(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buf := new(bytes.Buffer)
if err := tmpls.ExecuteTemplate(buf, "web.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write(buf.Bytes())
}
func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request) { func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request) {
data, err := s.getNodeData(r.Context()) data, err := s.getNodeData(r.Context())
if err != nil { if err != nil {
@ -354,7 +335,6 @@ func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
return
} }
type nodeUpdate struct { type nodeUpdate struct {

@ -1,210 +0,0 @@
<!doctype html>
<html class="bg-gray-50">
<head>
<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" />
<title>Tailscale</title>
<style>{{template "web.css"}}</style>
</head>
<body class="py-14">
<main class="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl" style="width: 95%">
<header class="flex justify-between items-center min-width-0 py-2 mb-8">
<svg width="26" height="26" viewBox="0 0 23 23" title="Tailscale" fill="none" xmlns="http://www.w3.org/2000/svg"
class="flex-shrink-0 mr-4">
<circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor"></circle>
<circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
<circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor"></circle>
</svg>
<div class="flex items-center justify-end space-x-2 w-2/3">
{{ with .Profile }}
<div class="text-right w-full leading-4">
<h4 class="truncate leading-normal">{{.LoginName}}</h4>
<div class="text-xs text-gray-500 text-right">
<a href="#" class="hover:text-gray-700 js-loginButton">Switch account</a> | <a href="#"
class="hover:text-gray-700 js-loginButton">Reauthenticate</a> | <a href="#"
class="hover:text-gray-700 js-logoutButton">Logout</a>
</div>
</div>
{{ end }}
<div class="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
{{ with .Profile.ProfilePicURL }}
<div class="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
style="background-image: url('{{.}}'); background-size: cover;"></div>
{{ else }}
<div class="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed"></div>
{{ end }}
</div>
</div>
</header>
{{ if .IP }}
<div
class="border border-gray-200 bg-gray-0 rounded-md p-2 pl-3 pr-3 width-full flex items-center justify-between">
<div class="flex items-center min-width-0">
<svg class="flex-shrink-0 text-gray-600 mr-3 ml-1" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
<div>
<h4 class="font-semibold truncate mr-2">{{.DeviceName}}</h4>
</div>
</div>
<h5>{{.IP}}</h5>
</div>
<p class="mt-1 ml-1 mb-6 text-xs text-gray-600">
Debug info: Tailscale {{ .IPNVersion }}, tun={{.TUNMode}}{{ if .IsSynology }}, DSM{{ .DSMVersion}}
{{if not .TUNMode}}
(<a href="https://tailscale.com/kb/1152/synology-outbound/" class="link-underline text-gray-600" target="_blank"
aria-label="Configure outbound synology traffic"
rel="noopener noreferrer">outgoing access not configured</a>)
{{end}}
{{end}}
</p>
{{ end }}
{{ if or (eq .Status "NeedsLogin") (eq .Status "NoState") }}
{{ if .IP }}
<div class="mb-6">
<p class="text-gray-700">Your device's key has expired. Reauthenticate this device by logging in again, or <a
href="https://tailscale.com/kb/1028/key-expiry" class="link" target="_blank">learn more</a>.</p>
</div>
<a href="#" class="mb-4 js-loginButton" target="_blank">
<button class="button button-blue w-full">Reauthenticate</button>
</a>
{{ else }}
<div class="mb-6">
<h3 class="text-3xl font-semibold mb-3">Log in</h3>
<p class="text-gray-700">Get started by logging in to your Tailscale network. Or,&nbsp;learn&nbsp;more at <a
href="https://tailscale.com/" class="link" target="_blank">tailscale.com</a>.</p>
</div>
<a href="#" class="mb-4 js-loginButton" target="_blank">
<button class="button button-blue w-full">Log In</button>
</a>
{{ end }}
{{ else if eq .Status "NeedsMachineAuth" }}
<div class="mb-4">
This device is authorized, but needs approval from a network admin before it can connect to the network.
</div>
{{ else }}
<div class="mb-4">
<p>You are connected! Access this device over Tailscale using the device name or IP address above.</p>
</div>
<div class="mb-4">
<a href="#" class="mb-4 js-advertiseExitNode">
{{if .AdvertiseExitNode}}
<button class="button button-red button-medium" id="enabled">Stop advertising Exit Node</button>
{{else}}
<button class="button button-blue button-medium" id="enabled">Advertise as Exit Node</button>
{{end}}
</a>
</div>
{{ end }}
</main>
<footer class="container max-w-lg mx-auto text-center">
<a class="text-xs text-gray-500 hover:text-gray-600" href="{{ .LicensesURL }}">Open Source Licenses</a>
</footer>
<script>(function () {
const advertiseExitNode = {{ .AdvertiseExitNode }};
const isUnraid = {{ .IsUnraid }};
const unraidCsrfToken = "{{ .UnraidToken }}";
let fetchingUrl = false;
var data = {
AdvertiseRoutes: "{{ .AdvertiseRoutes }}",
AdvertiseExitNode: advertiseExitNode,
Reauthenticate: false,
ForceLogout: false
};
function postData(e) {
e.preventDefault();
if (fetchingUrl) {
return;
}
fetchingUrl = true;
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("SynoToken");
const nextParams = new URLSearchParams({ up: true });
if (token) {
nextParams.set("SynoToken", token)
}
const nextUrl = new URL(window.location);
nextUrl.search = nextParams.toString()
let body = JSON.stringify(data);
let contentType = "application/json";
if (isUnraid) {
const params = new URLSearchParams();
params.append("csrf_token", unraidCsrfToken);
params.append("ts_data", JSON.stringify(data));
body = params.toString();
contentType = "application/x-www-form-urlencoded;charset=UTF-8";
}
const url = nextUrl.toString();
fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": contentType,
},
body: body
}).then(res => res.json()).then(res => {
fetchingUrl = false;
const err = res["error"];
if (err) {
throw new Error(err);
}
const url = res["url"];
if (url) {
if(isUnraid) {
window.open(url, "_blank");
} else {
document.location.href = url;
}
} else {
location.reload();
}
}).catch(err => {
alert("Failed operation: " + err.message);
});
}
document.querySelectorAll(".js-loginButton").forEach(function (el){
el.addEventListener("click", function(e) {
data.Reauthenticate = true;
postData(e);
});
})
document.querySelectorAll(".js-logoutButton").forEach(function(el) {
el.addEventListener("click", function (e) {
data.ForceLogout = true;
postData(e);
});
})
document.querySelectorAll(".js-advertiseExitNode").forEach(function (el) {
el.addEventListener("click", function(e) {
data.AdvertiseExitNode = !advertiseExitNode;
postData(e);
});
})
})();</script>
</body>
</html>

@ -251,7 +251,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
hash/crc32 from compress/gzip+ hash/crc32 from compress/gzip+
hash/maphash from go4.org/mem hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnstate+ html from tailscale.com/ipn/ipnstate+
html/template from tailscale.com/client/web+ html/template from github.com/gorilla/csrf
image from github.com/skip2/go-qrcode+ image from github.com/skip2/go-qrcode+
image/color from github.com/skip2/go-qrcode+ image/color from github.com/skip2/go-qrcode+
image/png from github.com/skip2/go-qrcode image/png from github.com/skip2/go-qrcode

@ -4,7 +4,7 @@
"type": "url", "type": "url",
"title": "Tailscale", "title": "Tailscale",
"icon": "PACKAGE_ICON_256.PNG", "icon": "PACKAGE_ICON_256.PNG",
"url": "webman/3rdparty/Tailscale/", "url": "webman/3rdparty/Tailscale/index.cgi/",
"urlTarget": "_syno_tailscale" "urlTarget": "_syno_tailscale"
} }
} }

Loading…
Cancel
Save