Compare commits
2 Commits
icio/testw
...
tsweb/clie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e76660843 | ||
|
|
9425312923 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,5 +35,8 @@ cmd/tailscaled/tailscaled
|
||||
# Ignore direnv nix-shell environment cache
|
||||
.direnv/
|
||||
|
||||
.vite/
|
||||
webui/node_modules
|
||||
|
||||
/gocross
|
||||
/dist
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"tailscale.com/util/cmpx"
|
||||
"tailscale.com/util/groupmember"
|
||||
"tailscale.com/version/distro"
|
||||
"tailscale.com/webui"
|
||||
)
|
||||
|
||||
//go:embed web.html
|
||||
@@ -91,6 +92,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
webf := newFlagSet("web")
|
||||
webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
|
||||
webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
|
||||
webf.BoolVar(&webArgs.dev, "dev", false, "run in dev mode")
|
||||
return webf
|
||||
})(),
|
||||
Exec: runWeb,
|
||||
@@ -99,6 +101,7 @@ Tailscale, as opposed to a CLI or a native app.
|
||||
var webArgs struct {
|
||||
listen string
|
||||
cgi bool
|
||||
dev bool
|
||||
}
|
||||
|
||||
func tlsConfigFromEnvironment() *tls.Config {
|
||||
@@ -129,8 +132,18 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
||||
}
|
||||
|
||||
handler := webHandler
|
||||
if true {
|
||||
newServer := &webui.Server{
|
||||
DevMode: webArgs.dev,
|
||||
}
|
||||
cleanup := webui.RunJSDevServer()
|
||||
defer cleanup()
|
||||
handler = newServer.Handle
|
||||
}
|
||||
|
||||
if webArgs.cgi {
|
||||
if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil {
|
||||
if err := cgi.Serve(http.HandlerFunc(handler)); err != nil {
|
||||
log.Printf("tailscale.cgi: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -142,14 +155,14 @@ func runWeb(ctx context.Context, args []string) error {
|
||||
server := &http.Server{
|
||||
Addr: webArgs.listen,
|
||||
TLSConfig: tlsConfig,
|
||||
Handler: http.HandlerFunc(webHandler),
|
||||
Handler: http.HandlerFunc(handler),
|
||||
}
|
||||
|
||||
log.Printf("web server running on: https://%s", server.Addr)
|
||||
return server.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
log.Printf("web server running on: %s", urlOfListenAddr(webArgs.listen))
|
||||
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
|
||||
return http.ListenAndServe(webArgs.listen, http.HandlerFunc(handler))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
tool/node
Executable file
60
tool/node
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run a command with our local node install, rather than any globally installed
|
||||
# instance.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
(
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
repo_root="${BASH_SOURCE%/*}/../"
|
||||
cd "$repo_root"
|
||||
|
||||
cachedir="$HOME/.cache/tailscale-node"
|
||||
tarball="${cachedir}.tar.gz"
|
||||
|
||||
read -r want_rev < "$(dirname "$0")/node.rev"
|
||||
|
||||
got_rev=""
|
||||
if [[ -x "${cachedir}/bin/node" ]]; then
|
||||
got_rev=$("${cachedir}/bin/node" --version)
|
||||
got_rev="${got_rev#v}" # trim the leading 'v'
|
||||
fi
|
||||
|
||||
if [[ "$want_rev" != "$got_rev" ]]; then
|
||||
rm -rf "$cachedir" "$tarball"
|
||||
if [[ -n "${IN_NIX_SHELL:-}" ]]; then
|
||||
nix_node="$(which -a node | grep /nix/store | head -1)"
|
||||
nix_node="${nix_node%/bin/node}"
|
||||
nix_node_rev="${nix_node##*-}"
|
||||
if [[ "$nix_node_rev" != "$want_rev" ]]; then
|
||||
echo "Wrong node version in Nix, got $nix_node_rev want $want_rev" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sf "$nix_node" "$cachedir"
|
||||
else
|
||||
# works for "linux" and "darwin"
|
||||
OS=$(uname -s | tr A-Z a-z)
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
ARCH="x64"
|
||||
fi
|
||||
if [ "$ARCH" = "aarch64" ]; then
|
||||
ARCH="arm64"
|
||||
fi
|
||||
mkdir -p "$cachedir"
|
||||
curl -f -L -o "$tarball" "https://nodejs.org/dist/v${want_rev}/node-v${want_rev}-${OS}-${ARCH}.tar.gz"
|
||||
(cd "$cachedir" && tar --strip-components=1 -xf "$tarball")
|
||||
rm -f "$tarball"
|
||||
fi
|
||||
fi
|
||||
)
|
||||
|
||||
export PATH="$HOME/.cache/tailscale-node/bin:$PATH"
|
||||
exec "$HOME/.cache/tailscale-node/bin/node" "$@"
|
||||
@@ -1 +1 @@
|
||||
16.4.1
|
||||
18.16.1
|
||||
|
||||
104
tool/yarn
104
tool/yarn
@@ -1,79 +1,43 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# This script acts like the "yarn" command, but uses Tailscale's
|
||||
# currently-desired version, downloading it (and node) first if necessary.
|
||||
#!/usr/bin/env bash
|
||||
# Run a command with our local yarn install, rather than any globally installed
|
||||
# instance.
|
||||
|
||||
set -eu
|
||||
set -euo pipefail
|
||||
|
||||
NODE_DIR="$HOME/.cache/tailscale-node"
|
||||
read -r YARN_REV < "$(dirname "$0")/yarn.rev"
|
||||
YARN_DIR="$HOME/.cache/tailscale-yarn"
|
||||
# This works for linux and darwin, which is sufficient
|
||||
# (we do not build for other targets).
|
||||
OS=$(uname -s | tr A-Z a-z)
|
||||
ARCH="$(uname -m)"
|
||||
if [ "$ARCH" = "aarch64" ]; then
|
||||
# Node uses the name "arm64".
|
||||
ARCH="arm64"
|
||||
elif [ "$ARCH" = "x86_64" ]; then
|
||||
# Node uses the name "x64".
|
||||
ARCH="x64"
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
install_node() {
|
||||
read -r NODE_REV < "$(dirname "$0")/node.rev"
|
||||
NODE_URL="https://nodejs.org/dist/v${NODE_REV}/node-v${NODE_REV}-${OS}-${ARCH}.tar.gz"
|
||||
install_tool "node" $NODE_REV $NODE_DIR $NODE_URL
|
||||
}
|
||||
|
||||
install_yarn() {
|
||||
YARN_URL="https://github.com/yarnpkg/yarn/releases/download/v$YARN_REV/yarn-v$YARN_REV.tar.gz"
|
||||
install_tool "yarn" $YARN_REV $YARN_DIR $YARN_URL
|
||||
}
|
||||
|
||||
install_tool() {
|
||||
TOOL=$1
|
||||
REV=$2
|
||||
TOOLCHAIN=$3
|
||||
URL=$4
|
||||
|
||||
archive="$TOOLCHAIN-$REV.tar.gz"
|
||||
mark="$TOOLCHAIN.extracted"
|
||||
extracted=
|
||||
[ ! -e "$mark" ] || read -r extracted junk <$mark
|
||||
|
||||
if [ "$extracted" = "$REV" ] && [ -e "$TOOLCHAIN/bin/$TOOL" ]; then
|
||||
# Already extracted, continue silently
|
||||
return 0
|
||||
(
|
||||
if [[ "${CI:-}" == "true" ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
rm -f "$archive.new" "$TOOLCHAIN.extracted"
|
||||
if [ ! -e "$archive" ]; then
|
||||
log "Need to download $TOOL '$REV' from $URL."
|
||||
curl -f -L -o "$archive.new" $URL
|
||||
rm -f "$archive"
|
||||
mv "$archive.new" "$archive"
|
||||
repo_root="${BASH_SOURCE%/*}/../"
|
||||
cd "$repo_root"
|
||||
|
||||
./tool/node --version >/dev/null # Ensure node is unpacked and ready
|
||||
|
||||
cachedir="$HOME/.cache/tailscale-yarn"
|
||||
tarball="${cachedir}.tar.gz"
|
||||
|
||||
read -r want_rev < "$(dirname "$0")/yarn.rev"
|
||||
|
||||
got_rev=""
|
||||
if [[ -x "${cachedir}/bin/yarn" ]]; then
|
||||
got_rev=$(PATH="$HOME/.cache/tailscale-node/bin:$PATH" "${cachedir}/bin/yarn" --version)
|
||||
fi
|
||||
|
||||
log "Extracting $TOOL '$REV' into '$TOOLCHAIN'." >&2
|
||||
rm -rf "$TOOLCHAIN"
|
||||
mkdir -p "$TOOLCHAIN"
|
||||
(cd "$TOOLCHAIN" && tar --strip-components=1 -xf "$archive")
|
||||
echo "$REV" >$mark
|
||||
}
|
||||
if [[ "$want_rev" != "$got_rev" ]]; then
|
||||
rm -rf "$cachedir" "$tarball"
|
||||
mkdir -p "$cachedir"
|
||||
curl -f -L -o "$tarball" "https://github.com/yarnpkg/yarn/releases/download/v${want_rev}/yarn-v${want_rev}.tar.gz"
|
||||
(cd "$cachedir" && tar --strip-components=1 -xf "$tarball")
|
||||
rm -f "$tarball"
|
||||
fi
|
||||
)
|
||||
|
||||
log() {
|
||||
echo "$@" >&2
|
||||
}
|
||||
|
||||
if [ "${YARN_REV}" = "SKIP" ] ||
|
||||
[ "${OS}" != "darwin" -a "${OS}" != "linux" ] ||
|
||||
[ "${ARCH}" != "x64" -a "${ARCH}" != "arm64" ]; then
|
||||
log "Using existing yarn (`which yarn`)."
|
||||
exec yarn "$@"
|
||||
fi
|
||||
|
||||
install_node
|
||||
install_yarn
|
||||
|
||||
exec /usr/bin/env PATH="$NODE_DIR/bin:$PATH" "$YARN_DIR/bin/yarn" "$@"
|
||||
# Deliberately not using cachedir here, to keep the environment
|
||||
# completely pristine for execution of yarn.
|
||||
export PATH="$HOME/.cache/tailscale-node/bin:$HOME/.cache/tailscale-yarn/bin:$PATH"
|
||||
exec "$HOME/.cache/tailscale-yarn/bin/yarn" "$@"
|
||||
|
||||
215
webui/index.html
Normal file
215
webui/index.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<!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>
|
||||
<link rel="stylesheet" href="/web.css">
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
@vite(["src/web.ts"])
|
||||
</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, learn 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>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
function() {
|
||||
// TODO: link up to data
|
||||
const advertiseExitNode = true;
|
||||
const isUnraid = false;
|
||||
const unraidCsrfToken = "csrfToken";
|
||||
let fetchingUrl = false;
|
||||
var data = {
|
||||
AdvertiseRoutes: "1.1.1.1/24",
|
||||
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>
|
||||
|
||||
</html>
|
||||
30
webui/package.json
Normal file
30
webui/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "18.16.1",
|
||||
"yarn": "1.22.19"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.1.6",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^4.3.9",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"vite-tsconfig-paths": "^3.5.0",
|
||||
"vite-plugin-svgr": "^3.2.0",
|
||||
"vite-plugin-rewrite-all": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"start": "vite",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
1380
webui/public/web.css
Normal file
1380
webui/public/web.css
Normal file
File diff suppressed because it is too large
Load Diff
3
webui/src/index.tsx
Normal file
3
webui/src/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
const rootEl = document.createElement("div")
|
||||
rootEl.textContent = "hello dev"
|
||||
document.body.append(rootEl)
|
||||
90
webui/src/web.ts
Normal file
90
webui/src/web.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export function run() {
|
||||
|
||||
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);
|
||||
});
|
||||
})
|
||||
}
|
||||
69
webui/vite.config.ts
Normal file
69
webui/vite.config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/// <reference types="vitest" />
|
||||
import { createLogger, defineConfig } from "vite"
|
||||
import rewrite from "vite-plugin-rewrite-all"
|
||||
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
|
||||
// admin panel 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(),
|
||||
// By default, the Vite dev server doesn't handle dots in path names and
|
||||
// treats them as static files, which breaks URLs like /admin/machines/100.101.102.103.
|
||||
// This plugin changes Vite's routing logic to fix this.
|
||||
// See: https://github.com/vitejs/vite/issues/2415
|
||||
rewrite(),
|
||||
],
|
||||
build: {
|
||||
outDir: "build",
|
||||
sourcemap: true,
|
||||
},
|
||||
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 adminhttp.go too.
|
||||
port: 4000,
|
||||
// Don't proxy the WebSocket connection used for live reloading by running
|
||||
// it on a separate port.
|
||||
hmr: {
|
||||
protocol: "ws",
|
||||
port: 4001,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
testTimeout: 20000,
|
||||
environment: "jsdom",
|
||||
deps: {
|
||||
inline: ["date-fns", /\.wasm\?url$/],
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
customLogger: filteringLogger,
|
||||
})
|
||||
68
webui/webui.go
Normal file
68
webui/webui.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package webui provides the Tailscale client for web.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
DevMode bool
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
}
|
||||
|
||||
func (s *Server) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
if s.DevMode {
|
||||
au, _ := url.Parse("http://127.0.0.1:4000")
|
||||
proxy := httputil.NewSingleHostReverseProxy(au)
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Hello production")
|
||||
}
|
||||
|
||||
func RunJSDevServer() (cleanup func()) {
|
||||
root := gitRootDir()
|
||||
webuiPath := filepath.Join(root, "webui")
|
||||
|
||||
yarn := filepath.Join(root, "tool", "yarn")
|
||||
node := filepath.Join(root, "tool", "node")
|
||||
vite := filepath.Join(webuiPath, "node_modules", ".bin", "vite")
|
||||
|
||||
log.Printf("installing JavaScript deps using %s... (might take ~30s)", yarn)
|
||||
out, err := exec.Command(yarn, "--non-interactive", "-s", "--cwd", webuiPath, "install").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Fatalf("error running admin panel's yarn install: %v, %s", err, out)
|
||||
}
|
||||
log.Printf("starting JavaScript dev server...")
|
||||
cmd := exec.Command(node, vite)
|
||||
cmd.Dir = webuiPath
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
1401
webui/yarn.lock
Normal file
1401
webui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user