mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-17 20:00:44 +02:00
- "Wake a device at home": the status page, reachable from anywhere over the tailnet, can make the console broadcast a Wake-on-LAN packet for a device in a saved list. - "Download diagnostics": one text file with the logs, version, firmware and settings for bug reports. E-mail addresses, the tailnet's name, public IP addresses, keys and the password are removed. - "test" links for forwards and game streaming hosts open a TCP connection through the tailnet and say in plain words how it went. - A short "what this does, and what it does not" note, opened before the first login. - Screenshot updated.
1003 lines
48 KiB
HTML
1003 lines
48 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Tailscale on PS5</title>
|
|
<link rel="icon" type="image/png" href="/favicon.png">
|
|
<style>
|
|
:root {
|
|
--bg: #f4f5f7; --panel: #ffffff; --text: #1c1e21; --muted: #646a73;
|
|
--line: #dfe2e6; --accent: #2f5fd0; --ok: #1f8a4c; --warn: #b26a00; --bad: #c0362c;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #14161a; --panel: #1e2126; --text: #e8eaed; --muted: #9aa1ab;
|
|
--line: #30353c; --accent: #7fa4ff; --ok: #56c488; --warn: #e0a84a; --bad: #f0796f;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--text);
|
|
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
main { max-width: 720px; margin: 0 auto; }
|
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
h2, summary .heading { font-size: 15px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
|
|
h2 { margin: 0 0 12px; }
|
|
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
|
|
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; }
|
|
.banner { border-color: var(--accent); }
|
|
.state { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 600; }
|
|
.dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted); flex: none; }
|
|
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
|
|
dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 14px 0 0; }
|
|
dt { color: var(--muted); } dd { margin: 0; overflow-wrap: anywhere; }
|
|
code { font: 14px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
a { color: var(--accent); }
|
|
.login { text-align: center; }
|
|
.login img { width: 240px; height: 240px; image-rendering: pixelated; background: #fff; padding: 8px; border-radius: 8px; }
|
|
.login .url { display: block; margin: 12px 0 4px; font-size: 18px; overflow-wrap: anywhere; }
|
|
.msg { color: var(--bad); margin: 12px 0 0; overflow-wrap: anywhere; }
|
|
.okmsg { color: var(--ok); margin: 12px 0 0; overflow-wrap: anywhere; }
|
|
.health { color: var(--warn); margin: 12px 0 0; padding-left: 18px; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
|
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px 8px 0; }
|
|
td { padding: 7px 8px 7px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
|
td.off { color: var(--muted); }
|
|
/* Names may wrap anywhere; addresses, OS and status stay in one piece. */
|
|
#peers td:nth-child(n+2), th { white-space: nowrap; overflow-wrap: normal; }
|
|
code.copy { cursor: pointer; border-bottom: 1px dotted var(--muted); }
|
|
code.copy:hover, code.copy:focus-visible { color: var(--accent); border-bottom-color: var(--accent); }
|
|
code.copy.done { color: var(--ok); border-bottom-color: transparent; }
|
|
@media (max-width: 480px) {
|
|
#peers code { font-size: 12.5px; } table { font-size: 14px; }
|
|
/* Label above value, so that names and addresses get the full width. */
|
|
dl { grid-template-columns: 1fr; gap: 0; }
|
|
dt { font-size: 13px; margin-top: 10px; }
|
|
/* The device gets a line of its own; port, protocol and Remove share the next. */
|
|
.fwd .hostrow { flex-wrap: wrap; }
|
|
.fwd .hostrow .host { flex: 1 1 100%; }
|
|
#wakerows .host, #wakerows .mac { flex: 1 1 100%; }
|
|
}
|
|
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
|
button {
|
|
font: inherit; padding: 9px 16px; border-radius: 8px; border: 1px solid var(--line);
|
|
background: var(--panel); color: var(--text); cursor: pointer;
|
|
}
|
|
button:hover { border-color: var(--accent); }
|
|
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
button.danger { color: var(--bad); }
|
|
button.small { padding: 6px 10px; font-size: 14px; }
|
|
pre { margin: 0; max-height: 320px; overflow: auto; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
details summary { cursor: pointer; color: var(--muted); }
|
|
summary .count { font-size: 14px; margin-left: 8px; }
|
|
summary .count { white-space: nowrap; }
|
|
details[open] > table, details[open] > form { margin-top: 12px; }
|
|
.note { color: var(--muted); font-size: 14px; margin: 0 0 12px; }
|
|
.hint { color: var(--muted); font-size: 13px; margin: 2px 0 0; }
|
|
label.field { display: flex; flex-direction: column; gap: 4px; font-size: 14px; color: var(--muted); margin-bottom: 14px; }
|
|
label.check { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
|
input[type=text], input[type=password], input[type=number], select, textarea {
|
|
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
|
background: var(--panel); color: var(--text); width: 100%;
|
|
}
|
|
textarea { font: 14px ui-monospace, Consolas, monospace; min-height: 70px; resize: vertical; }
|
|
.filters { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; margin-top: 12px; }
|
|
.filters input[type=search] {
|
|
font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
|
|
background: var(--panel); color: var(--text); flex: 1 1 200px; min-width: 0;
|
|
}
|
|
.filters label.check { margin: 0; font-size: 14px; }
|
|
tr.group td { color: var(--muted); font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding-top: 14px; border-top: 0; }
|
|
td .tag { display: block; color: var(--muted); font-size: 13px; }
|
|
.hostrow { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
|
.hostrow .host { flex: 3; min-width: 0; }
|
|
.hostrow .port { flex: 1; min-width: 90px; }
|
|
.hostrow select { flex: 0 0 auto; width: auto; }
|
|
.fwd { margin-bottom: 12px; }
|
|
.fwd .hostrow { margin-bottom: 4px; }
|
|
.fwd .use { font-size: 14px; color: var(--muted); }
|
|
button.link { border: 0; padding: 0; background: none; color: var(--accent); font-size: inherit; text-decoration: underline; cursor: pointer; }
|
|
ul.about { margin: 12px 0 0; padding-left: 20px; }
|
|
ul.about li { margin-bottom: 8px; }
|
|
.hostrow .mac { flex: 2; min-width: 0; font-family: ui-monospace, Consolas, monospace; font-size: 14px; }
|
|
.result { font-size: 14px; color: var(--muted); }
|
|
.result.good { color: var(--ok); } .result.bad { color: var(--bad); }
|
|
.hidden { display: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Tailscale on PS5</h1>
|
|
<p class="sub" id="version"></p>
|
|
|
|
<section class="panel banner hidden" id="updatepanel">
|
|
<strong id="updatetext"></strong>
|
|
<a id="updatelink" target="_blank" rel="noopener">Release notes and download</a>
|
|
<div class="actions" style="margin-top: 12px">
|
|
<button id="btn-update" class="hidden">Install it</button>
|
|
</div>
|
|
<p class="health hidden" id="updatewarn" style="padding-left: 0"></p>
|
|
<p class="note hidden" id="updatestate" style="margin: 12px 0 0"></p>
|
|
<p class="msg hidden" id="updatemsg"></p>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="lockpanel">
|
|
<h2>Password</h2>
|
|
<p class="note">This status page is password protected.</p>
|
|
<form id="lockform" class="actions">
|
|
<input type="password" id="lockpw" autocomplete="current-password" aria-label="Password" style="max-width: 280px">
|
|
<button type="submit">Unlock</button>
|
|
</form>
|
|
<p class="msg hidden" id="lockmsg"></p>
|
|
</section>
|
|
|
|
<div id="content" class="hidden">
|
|
<section class="panel">
|
|
<div class="state"><span class="dot" id="dot"></span><span id="state">Loading…</span></div>
|
|
<dl id="facts"></dl>
|
|
<ul class="health hidden" id="health"></ul>
|
|
<p class="health hidden" id="keywarn" style="padding-left: 0"></p>
|
|
<p class="msg hidden" id="error"></p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<details id="aboutbox">
|
|
<summary><span class="heading">What this does, and what it does not</span></summary>
|
|
<ul class="about">
|
|
<li><strong>Your other devices can reach this PS5</strong> at its Tailscale address, from anywhere: FTP, the
|
|
payload loader, Remote Play, anything that listens on the console.</li>
|
|
<li><strong>The PS5 can reach a device on your tailnet</strong> through an address on the console itself. Set
|
|
that up under "Reach a device from this PS5"; game streaming has its own panel.</li>
|
|
<li><strong>It is not a full VPN.</strong> Games, PSN and the PS5's browser keep using your normal internet
|
|
connection, the console's public IP address does not change, and the console cannot use an exit node. The
|
|
PS5 has no way to route its own traffic through Tailscale.</li>
|
|
</ul>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="panel login hidden" id="login">
|
|
<h2>Log in</h2>
|
|
<p>Scan this with your phone, or open the link on any device, to add this PS5 to your tailnet.</p>
|
|
<img id="qr" alt="QR code for the login link">
|
|
<a class="url" id="authurl" target="_blank" rel="noopener"></a>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="peerpanel">
|
|
<details id="peerbox" open>
|
|
<summary><span class="heading">Devices on your tailnet</span><span class="count" id="peercount"></span></summary>
|
|
<div class="filters">
|
|
<input type="search" id="peersearch" placeholder="Search name, address, OS, tag or place" aria-label="Search devices" autocomplete="off">
|
|
<label class="check"><input type="checkbox" id="peeronline"> Online only</label>
|
|
<label class="check hidden" id="peervpn-row"><input type="checkbox" id="peervpn"> <span id="peervpn-text"></span></label>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Address</th><th>OS</th><th>Status</th></tr></thead>
|
|
<tbody id="peers"></tbody>
|
|
</table>
|
|
<p class="note hidden" id="peernone" style="margin: 12px 0 0">No device matches.</p>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="filespanel">
|
|
<details id="filesbox">
|
|
<summary><span class="heading">Received files</span><span class="count" id="filescount"></span></summary>
|
|
<p class="note" style="margin-top: 12px">Files sent to this console with Taildrop, from your own devices, are kept in
|
|
<code id="filesdir"></code>.</p>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Size</th><th>Received</th></tr></thead>
|
|
<tbody id="files"></tbody>
|
|
</table>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="reachpanel">
|
|
<h2>Reach a device from this PS5</h2>
|
|
<p class="note">Apps and the browser on the PS5 cannot open tailnet addresses. Add the device and the port you
|
|
need, and on the PS5 use the address shown for it instead.</p>
|
|
<div id="fwdrows"></div>
|
|
<div class="actions">
|
|
<button id="btn-addfwd" class="small">Add a device</button>
|
|
<button id="btn-fwds">Save</button>
|
|
</div>
|
|
<p class="hint">Example: a media server on port 8096 of <i>my-nas</i> becomes <code>127.0.0.1:8096</code> on the
|
|
PS5. Pages that insist on their own name, and HTTPS sites, may not work this way. Game streaming has its own
|
|
panel below.</p>
|
|
<p class="okmsg hidden" id="fwdok"></p>
|
|
<p class="msg hidden" id="fwdmsg"></p>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="streampanel">
|
|
<h2>Game streaming (Moonlight to Sunshine)</h2>
|
|
<p class="note">Apps on the PS5 cannot reach tailnet addresses directly. List the devices that run Sunshine and
|
|
their streaming ports are made available on this console. In your Moonlight client on the PS5 (for example
|
|
ProsperoLight), add the address shown for each host.</p>
|
|
<div id="hostrows"></div>
|
|
<datalist id="peernames"></datalist>
|
|
<div class="actions">
|
|
<button id="btn-addhost" class="small">Add a host</button>
|
|
<button id="btn-sunshine">Save</button>
|
|
</div>
|
|
<p class="hint">Port is Sunshine's own port setting; leave it empty for the default (47989). Two hosts need
|
|
different ports.</p>
|
|
<p class="note" id="sunshine-state" style="margin: 12px 0 0"></p>
|
|
</section>
|
|
|
|
<section class="panel hidden" id="wakepanel">
|
|
<h2>Wake a device at home</h2>
|
|
<p class="note">A sleeping PC cannot be reached over Tailscale, but this console is on the same home network and
|
|
can send it a Wake-on-LAN packet. Open this page from wherever you are, press Wake, wait half a minute, then
|
|
connect. The device needs Wake-on-LAN turned on, and a wired connection works best.</p>
|
|
<div id="wakerows"></div>
|
|
<div class="actions">
|
|
<button id="btn-addwake" class="small">Add a device</button>
|
|
<button id="btn-wakes">Save</button>
|
|
</div>
|
|
<p class="hint">The address is the network card's MAC address, such as <code>00:11:22:AA:BB:CC</code>. On Windows
|
|
it is the "Physical address" shown by <code>ipconfig /all</code>.</p>
|
|
<p class="okmsg hidden" id="wakeok"></p>
|
|
<p class="msg hidden" id="wakemsg"></p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<details id="settingsbox">
|
|
<summary><span class="heading">Settings</span></summary>
|
|
<form id="settingsform">
|
|
<label class="field">Name on the tailnet
|
|
<input type="text" id="set-hostname" maxlength="63" autocomplete="off">
|
|
</label>
|
|
<label class="field">Password for this page <span id="set-pwstate"></span>
|
|
<input type="password" id="set-password" autocomplete="new-password" placeholder="Leave empty to keep it as it is">
|
|
<span class="hint">Asked on every device except the console itself. If you forget it, delete
|
|
<code>passwordHash</code> from <code>/data/tailscale/config.json</code>.</span>
|
|
</label>
|
|
<label class="check hidden" id="set-pwremove-row"><input type="checkbox" id="set-pwremove"> Remove the password</label>
|
|
<label class="field">Who on the tailnet may connect to this console
|
|
<select id="set-allowfrom">
|
|
<option value="all">Every device my tailnet's access rules allow (default)</option>
|
|
<option value="own">Only devices logged in as the same user as this console</option>
|
|
</select>
|
|
<span class="hint">The second choice turns away other users' devices and devices shared into your
|
|
tailnet, for every port and this page. The page stays reachable on your home network.</span>
|
|
</label>
|
|
<label class="field">UDP ports reachable from the tailnet
|
|
<input type="text" id="set-udp" autocomplete="off">
|
|
<span class="hint">Comma separated. 9295, 9296, 9297, 9302 are Remote Play's. Empty turns inbound UDP off.</span>
|
|
</label>
|
|
<label class="field">TCP ports never exposed to the tailnet
|
|
<input type="text" id="set-blocked" autocomplete="off">
|
|
<span class="hint">Comma separated. Every other open TCP port on the console is reachable.</span>
|
|
</label>
|
|
<label class="field">HTTP proxy address
|
|
<input type="text" id="set-proxy" autocomplete="off" placeholder="Off">
|
|
<span class="hint">Empty is off. Example: <code>127.0.0.1:8118</code>. Do not set it as the PS5's system proxy.</span>
|
|
</label>
|
|
<label class="field">Priority
|
|
<select id="set-priority">
|
|
<option value="low">Low: never takes time from a game (default)</option>
|
|
<option value="high">High: shares the CPU with games; smoother Remote Play</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">Folder for files received with Taildrop
|
|
<input type="text" id="set-receivedir" autocomplete="off">
|
|
</label>
|
|
<label class="field">Payload file to keep up to date
|
|
<input type="text" id="set-payloadpath" autocomplete="off" placeholder="None">
|
|
<span class="hint">Where the copy that starts with the console is kept, for example
|
|
<code>/data/pldmgr/payloads/Tailscale/tailscale.elf</code>. Installing an update from this page then
|
|
replaces that file too; it keeps its name, and your autoload entry keeps working. Give that copy a
|
|
fixed name such as <code>tailscale.elf</code>, without a version in it, so the name stays true.
|
|
Empty: only the running copy is updated, until the next restart.</span>
|
|
</label>
|
|
<label class="field">Status page address
|
|
<input type="text" id="set-webaddr" autocomplete="off">
|
|
</label>
|
|
<label class="check"><input type="checkbox" id="set-updates"> Check GitHub for new releases</label>
|
|
<label class="check"><input type="checkbox" id="set-verbose"> Verbose log</label>
|
|
<div class="actions"><button type="submit">Save settings</button></div>
|
|
<p class="okmsg hidden" id="settingsok"></p>
|
|
<p class="msg hidden" id="settingsmsg"></p>
|
|
</form>
|
|
</details>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>Actions</h2>
|
|
<div class="actions">
|
|
<button id="btn-login">Log in again</button>
|
|
<button id="btn-logout" class="danger">Log out</button>
|
|
<button id="btn-quit" class="danger">Stop Tailscale</button>
|
|
<button id="btn-uninstall" class="danger">Uninstall</button>
|
|
<button id="btn-diag">Download diagnostics</button>
|
|
<button id="btn-lock" class="hidden">Lock this page</button>
|
|
</div>
|
|
<p class="hint" style="margin-top: 10px">Diagnostics is one text file with the logs, version and settings, for
|
|
attaching to a bug report. E-mail addresses, your tailnet's name, public IP addresses and the password are
|
|
removed; device names are not. Read it before posting it.</p>
|
|
<p class="okmsg hidden" id="actionok"></p>
|
|
<p class="msg hidden" id="actionmsg"></p>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<details id="logbox">
|
|
<summary><span class="heading">Recent log</span></summary>
|
|
<pre id="logs"></pre>
|
|
</details>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const labels = {
|
|
Starting: ['Starting…', 'warn'],
|
|
NoState: ['Starting…', 'warn'],
|
|
NeedsLogin: ['Not logged in', 'warn'],
|
|
NeedsMachineAuth: ['Waiting for approval in the admin console', 'warn'],
|
|
Stopped: ['Stopped', 'bad'],
|
|
Running: ['Connected', 'ok'],
|
|
};
|
|
let shownQR = '';
|
|
let hostsDirty = false; // the user is editing the Sunshine hosts
|
|
let lastHosts = '';
|
|
|
|
// api performs a request to the daemon. A 401 means the page is locked.
|
|
async function api(path, options) {
|
|
options = options || {};
|
|
options.cache = 'no-store';
|
|
options.headers = Object.assign({'X-PS5-Tailscale': '1'}, options.headers || {});
|
|
const r = await fetch(path, options);
|
|
if (r.status === 401) { showLocked(true); throw new Error('locked'); }
|
|
return r;
|
|
}
|
|
|
|
function showLocked(locked) {
|
|
$('lockpanel').classList.toggle('hidden', !locked);
|
|
$('content').classList.toggle('hidden', locked);
|
|
if (locked) $('updatepanel').classList.add('hidden');
|
|
}
|
|
|
|
function show(id, text) {
|
|
const el = $(id);
|
|
el.textContent = text || '';
|
|
el.classList.toggle('hidden', !text);
|
|
}
|
|
|
|
// The page is served over plain HTTP, where browsers do not offer the
|
|
// clipboard API, so fall back to the old way of copying a selection.
|
|
async function copyText(text) {
|
|
try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
|
|
const t = document.createElement('textarea');
|
|
t.value = text; t.setAttribute('readonly', ''); t.style.position = 'fixed'; t.style.opacity = '0';
|
|
document.body.append(t); t.select();
|
|
let ok = false;
|
|
try { ok = document.execCommand('copy'); } catch (e) {}
|
|
t.remove();
|
|
return ok;
|
|
}
|
|
|
|
// copyable returns a piece of monospace text that is copied when clicked.
|
|
function copyable(text) {
|
|
const c = document.createElement('code');
|
|
c.className = 'copy'; c.textContent = text; c.title = 'Copy'; c.tabIndex = 0;
|
|
c.setAttribute('role', 'button'); c.setAttribute('aria-label', 'Copy ' + text);
|
|
const go = async () => {
|
|
if (c.classList.contains('done')) return;
|
|
const ok = await copyText(text);
|
|
c.textContent = ok ? 'Copied' : 'Copy failed'; c.classList.add('done');
|
|
setTimeout(() => { c.textContent = text; c.classList.remove('done'); }, 1200);
|
|
};
|
|
c.onclick = go;
|
|
c.onkeydown = ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); go(); } };
|
|
return c;
|
|
}
|
|
|
|
// row adds a fact. mono shows the value in monospace; an array of values is
|
|
// shown as separately copyable pieces.
|
|
function row(dl, name, value, mono) {
|
|
const dt = document.createElement('dt'); dt.textContent = name;
|
|
const dd = document.createElement('dd');
|
|
if (Array.isArray(value)) value.forEach((v, i) => { if (i) dd.append(', '); dd.append(copyable(v)); });
|
|
else if (mono) { const c = document.createElement('code'); c.textContent = value; dd.append(c); }
|
|
else dd.textContent = value;
|
|
dl.append(dt, dd);
|
|
}
|
|
|
|
// Facts are rebuilt on every refresh; skip that while nothing changed so a
|
|
// "Copied" confirmation is not wiped out mid-way.
|
|
let shownFacts = '';
|
|
|
|
// testTarget asks the daemon whether a device answers on a port, the way a
|
|
// forward would reach it, and writes the outcome into el.
|
|
async function testTarget(target, el) {
|
|
el.className = 'result'; el.textContent = 'testing…';
|
|
try {
|
|
const r = await api('/api/testtarget?target=' + encodeURIComponent(target), {method: 'POST'});
|
|
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
|
const t = await r.json();
|
|
el.className = 'result ' + (t.ok ? 'good' : 'bad');
|
|
el.textContent = t.ok ? 'answers (' + Math.round(t.ms) + ' ms)' : t.error;
|
|
} catch (e) {
|
|
el.className = 'result bad'; el.textContent = e.message === 'locked' ? '' : e.message;
|
|
}
|
|
}
|
|
|
|
// testLink is a small "test" link with room for its result next to it.
|
|
function testLink(target, title) {
|
|
const span = document.createElement('span');
|
|
const b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'link'; b.textContent = 'test'; b.title = title;
|
|
const res = document.createElement('span');
|
|
b.onclick = () => testTarget(target(), res);
|
|
span.append(b, ' ', res);
|
|
return span;
|
|
}
|
|
|
|
function hostRow(host, port) {
|
|
const div = document.createElement('div');
|
|
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
|
|
const h = document.createElement('input');
|
|
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = host || '';
|
|
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Sunshine host'); h.autocomplete = 'off';
|
|
const p = document.createElement('input');
|
|
p.type = 'number'; p.className = 'port'; p.placeholder = '47989'; p.min = 1029; p.max = 65514;
|
|
p.value = port && port !== 47989 ? port : ''; p.setAttribute('aria-label', 'Sunshine port');
|
|
const x = document.createElement('button');
|
|
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
|
x.onclick = () => { div.remove(); hostsDirty = true; };
|
|
h.oninput = p.oninput = () => { hostsDirty = true; };
|
|
// Whether Sunshine on that device answers: its web port is the one the
|
|
// Moonlight client talks to first.
|
|
const t = testLink(() => {
|
|
const name = h.value.trim();
|
|
return (name.includes(':') ? '[' + name + ']' : name) + ':' + (parseInt(p.value, 10) || 47989);
|
|
}, 'Check whether Sunshine on this device answers');
|
|
t.style.flex = '1 1 100%'; t.style.fontSize = '14px';
|
|
div.append(h, p, x, t);
|
|
return div;
|
|
}
|
|
|
|
// The device list. Peers come sorted from the daemon (online first); the page
|
|
// groups them by kind and applies the filters. Exit servers of a VPN add-on
|
|
// can be hundreds, so they are only requested while their box is ticked.
|
|
const kinds = [['own', 'This tailnet'], ['shared', 'Shared with you'], ['vpn', 'VPN exit servers']];
|
|
let peers = [];
|
|
let shownPeers = '';
|
|
|
|
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
|
|
function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
|
|
|
|
function peerMatches(p, words) {
|
|
const text = [p.name, p.ip, p.os, p.location || '', (p.tags || []).join(' '), p.exitNode ? 'exit node' : '',
|
|
p.online ? 'online' : 'offline'].join(' ').toLowerCase();
|
|
return words.every(w => text.includes(w));
|
|
}
|
|
|
|
// How the console reaches a device: straight, or through a relay server,
|
|
// which is slower. Measured on request, since measuring wakes the connection.
|
|
const pings = {}; // tailnet address -> last test result, as text
|
|
function connText(conn, via) {
|
|
return conn === 'direct' ? 'direct' : conn === 'relay' ? 'relayed' + (via ? ' via ' + via : '') : '';
|
|
}
|
|
async function testPeer(ip) {
|
|
pings[ip] = 'testing…'; shownPeers = ''; renderPeers();
|
|
try {
|
|
const r = await api('/api/pingpeer?ip=' + encodeURIComponent(ip), {method: 'POST'});
|
|
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
|
const p = await r.json();
|
|
pings[ip] = connText(p.conn, p.via) + ', ' + (p.latencyMs < 10 ? p.latencyMs.toFixed(1) : Math.round(p.latencyMs)) + ' ms';
|
|
} catch (e) {
|
|
pings[ip] = e.message === 'locked' ? '' : 'no answer';
|
|
}
|
|
shownPeers = ''; renderPeers();
|
|
// A measurement says nothing about a minute from now.
|
|
const shown = pings[ip];
|
|
setTimeout(() => { if (pings[ip] === shown) { delete pings[ip]; shownPeers = ''; renderPeers(); } }, 60000);
|
|
}
|
|
|
|
function peerRow(p) {
|
|
const tr = document.createElement('tr');
|
|
const notes = [];
|
|
if (p.exitNode === 'used') notes.push('exit node in use');
|
|
else if (p.exitNode) notes.push('exit node');
|
|
if (p.location) notes.push(p.location);
|
|
if (p.tags) notes.push(p.tags.join(', '));
|
|
if (pings[p.ip]) notes.push(pings[p.ip]);
|
|
else if (p.conn) notes.push(connText(p.conn, p.via));
|
|
for (const [v, mono, first] of [[p.name, false, true], [p.ip, true], [p.os], [p.online ? 'online' : 'offline']]) {
|
|
const td = document.createElement('td');
|
|
if (!p.online) td.className = 'off';
|
|
if (mono) td.append(v ? copyable(v) : ''); else td.textContent = v;
|
|
if (first && (notes.length || (p.online && p.ip))) {
|
|
const n = document.createElement('span'); n.className = 'tag'; n.textContent = notes.join(' · ');
|
|
if (p.online && p.ip && pings[p.ip] !== 'testing…') {
|
|
if (notes.length) n.append(' · ');
|
|
const b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'link'; b.textContent = 'test'; b.title = 'Measure the connection to this device';
|
|
b.onclick = () => testPeer(p.ip);
|
|
n.append(b);
|
|
}
|
|
td.append(n);
|
|
}
|
|
tr.append(td);
|
|
}
|
|
return tr;
|
|
}
|
|
|
|
function renderPeers() {
|
|
const words = $('peersearch').value.toLowerCase().split(/\s+/).filter(w => w);
|
|
const onlineOnly = $('peeronline').checked;
|
|
const shown = peers.filter(p => (!onlineOnly || p.online) && peerMatches(p, words));
|
|
// Rebuilding hundreds of rows every few seconds is wasteful; only do it
|
|
// when what is shown changed.
|
|
const key = JSON.stringify([shown, pings]);
|
|
if (key === shownPeers) return;
|
|
shownPeers = key;
|
|
const groups = kinds.map(([kind, title]) => [title, shown.filter(p => p.kind === kind)]).filter(g => g[1].length);
|
|
const rows = [];
|
|
for (const [title, list] of groups) {
|
|
if (groups.length > 1) {
|
|
const tr = document.createElement('tr'); tr.className = 'group';
|
|
const td = document.createElement('td'); td.colSpan = 4;
|
|
td.textContent = title + ' (' + list.length + ')';
|
|
tr.append(td); rows.push(tr);
|
|
}
|
|
rows.push(...list.map(peerRow));
|
|
}
|
|
$('peers').replaceChildren(...rows);
|
|
$('peernone').classList.toggle('hidden', shown.length > 0);
|
|
}
|
|
|
|
// "Reach a device from this PS5": forwards from a port on the console to a
|
|
// device on the tailnet. A row keeps the local address it was saved with; a
|
|
// new row gets one when it is saved.
|
|
let fwdsDirty = false;
|
|
let lastFwds = '';
|
|
function fwdRow(rule) {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'fwd';
|
|
wrap.dataset.listen = rule ? rule.listen : '';
|
|
const i = rule ? rule.target.lastIndexOf(':') : -1;
|
|
const div = document.createElement('div');
|
|
div.className = 'hostrow';
|
|
const h = document.createElement('input');
|
|
h.type = 'text'; h.className = 'host'; h.placeholder = 'Device name or address'; h.value = rule ? rule.target.slice(0, i).replace(/^\[|\]$/g, '') : '';
|
|
h.setAttribute('list', 'peernames'); h.setAttribute('aria-label', 'Device'); h.autocomplete = 'off';
|
|
const p = document.createElement('input');
|
|
p.type = 'number'; p.className = 'port'; p.placeholder = 'Port'; p.min = 1; p.max = 65535;
|
|
p.value = rule ? rule.target.slice(i + 1) : ''; p.setAttribute('aria-label', 'Port on the device');
|
|
const proto = document.createElement('select');
|
|
proto.setAttribute('aria-label', 'Protocol');
|
|
for (const v of ['tcp', 'udp']) { const o = document.createElement('option'); o.value = v; o.textContent = v.toUpperCase(); proto.append(o); }
|
|
proto.value = rule ? rule.proto : 'tcp';
|
|
const x = document.createElement('button');
|
|
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
|
x.onclick = () => { wrap.remove(); fwdsDirty = true; };
|
|
const use = document.createElement('div');
|
|
use.className = 'use';
|
|
if (rule) {
|
|
use.append('On the PS5 use ', copyable(rule.listen));
|
|
// Only TCP can be tested: UDP has no "it answered".
|
|
if (rule.proto === 'tcp') use.append(' · ', testLink(() => rule.target, 'Check whether the device answers on that port'));
|
|
} else use.textContent = 'The address to use on the PS5 appears here after saving.';
|
|
// Changing what a row points at keeps its address; changing the port or
|
|
// protocol gives it a new one, so it stays easy to recognise.
|
|
h.oninput = () => { fwdsDirty = true; };
|
|
p.oninput = proto.onchange = () => { fwdsDirty = true; wrap.dataset.listen = ''; use.textContent = 'The address to use on the PS5 appears here after saving.'; };
|
|
div.append(h, p, proto, x);
|
|
wrap.append(div, use);
|
|
return wrap;
|
|
}
|
|
|
|
// localPortFor picks the port on the console for a new forward: the same
|
|
// number as on the device where that is possible, so it is easy to remember.
|
|
function localPortFor(port, proto, taken) {
|
|
let local = port < 1024 ? port + 8000 : port;
|
|
while (taken.has(proto + local) || local === 8090) local++;
|
|
taken.add(proto + local);
|
|
return local;
|
|
}
|
|
|
|
async function refresh() {
|
|
let s;
|
|
try {
|
|
s = await (await api('/api/status' + ($('peervpn').checked ? '?vpn=1' : ''))).json();
|
|
} catch (e) {
|
|
if (e.message !== 'locked') {
|
|
$('state').textContent = 'Not responding';
|
|
$('dot').className = 'dot bad';
|
|
}
|
|
return;
|
|
}
|
|
showLocked(false);
|
|
const [label, cls] = labels[s.state] || [s.state, 'warn'];
|
|
$('state').textContent = label;
|
|
$('dot').className = 'dot ' + cls;
|
|
$('version').textContent = 'ps5-tailscale ' + s.version;
|
|
|
|
const upd = s.update || {};
|
|
const busy = ['downloading', 'verifying', 'installing'].includes(upd.state);
|
|
$('updatepanel').classList.toggle('hidden', !s.latestVersion && !upd.state);
|
|
if (s.latestVersion) {
|
|
$('updatetext').textContent = 'Version ' + s.latestVersion + ' is available. ';
|
|
$('updatelink').href = s.updateURL;
|
|
}
|
|
$('btn-update').classList.toggle('hidden', !s.canUpdate || busy);
|
|
$('btn-update').textContent = 'Install ' + (s.latestVersion || 'it');
|
|
// Installing replaces the running copy. What starts with the console is
|
|
// a file somewhere else, which the user has to keep an eye on.
|
|
autoloadWarning = s.payloadPath
|
|
? 'Installing also replaces the copy at ' + s.payloadPath + '. Afterwards, check your autoload settings: your payload manager or autoloader must still start that file.'
|
|
: 'After installing, check your autoload settings. If a payload manager or autoloader starts an older Tailscale file with the console, the old version comes back after a restart: replace that file with the new release, or name it under Settings ("Payload file to keep up to date") before installing.';
|
|
show('updatewarn', s.canUpdate && !busy ? autoloadWarning : '');
|
|
show('updatestate', {
|
|
downloading: 'Downloading version ' + upd.version + (upd.percent ? ' (' + upd.percent + '%)' : '') + '…',
|
|
verifying: 'Checking the signature of version ' + upd.version + '…',
|
|
installing: 'Starting version ' + upd.version + '. This page reconnects by itself.',
|
|
}[upd.state] || '');
|
|
show('updatemsg', upd.state === 'failed' ? 'The update was not installed: ' + upd.error : updateRefused);
|
|
|
|
// Key expiry, in whole days.
|
|
let keyText = '', keyWarn = '';
|
|
if (s.keyExpiry) {
|
|
const when = new Date(s.keyExpiry), days = Math.floor((when - Date.now()) / 86400000);
|
|
const date = when.toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
|
const left = days < 0 ? 'expired' : days === 0 ? 'in less than a day' : 'in ' + days + (days === 1 ? ' day' : ' days');
|
|
keyText = date + ' (' + left + ')';
|
|
if (days < 14 && s.state === 'Running') {
|
|
keyWarn = 'This console\'s Tailscale key expires ' + left + '. After that it drops off your tailnet until you ' +
|
|
'use "Log in again" below. To stop it expiring, open the Tailscale admin console, find this device and ' +
|
|
'choose "Disable key expiry".';
|
|
}
|
|
}
|
|
show('keywarn', keyWarn);
|
|
|
|
const factsNow = JSON.stringify([s.dnsName, s.hostname, s.ips, s.tailnet, s.udpPorts, s.proxy, s.priority, s.allowFrom, keyText, s.dns]);
|
|
if (factsNow !== shownFacts) {
|
|
shownFacts = factsNow;
|
|
const dl = $('facts');
|
|
dl.replaceChildren();
|
|
row(dl, 'Name', s.dnsName || s.hostname);
|
|
if (s.ips.length) row(dl, 'Tailnet address', s.ips);
|
|
if (s.tailnet) row(dl, 'Tailnet', s.tailnet);
|
|
if (s.ips.length) row(dl, 'Reachable from tailnet', 'every open TCP port' + (s.udpPorts.length ? '; UDP ' + s.udpPorts.join(', ') + ' (Remote Play)' : '')
|
|
+ (s.allowFrom === 'own' ? '; only from your own devices' : ''));
|
|
if (keyText) row(dl, 'Key expires', keyText);
|
|
if (s.dns) row(dl, 'Name lookups', s.dns);
|
|
if (s.proxy) row(dl, 'HTTP proxy', s.proxy, true);
|
|
if (s.priority === 'high') row(dl, 'Priority', 'High');
|
|
}
|
|
|
|
const health = $('health');
|
|
health.replaceChildren(...(s.health || []).map(h => { const li = document.createElement('li'); li.textContent = h; return li; }));
|
|
health.classList.toggle('hidden', !(s.health || []).length);
|
|
show('error', s.error);
|
|
|
|
const needLogin = !!s.authURL;
|
|
$('login').classList.toggle('hidden', !needLogin);
|
|
if (needLogin) {
|
|
$('authurl').textContent = s.authURL;
|
|
$('authurl').href = s.authURL;
|
|
if (shownQR !== s.authURL) { shownQR = s.authURL; $('qr').src = '/qr.png?' + Date.now(); }
|
|
}
|
|
|
|
peers = s.peers;
|
|
const devices = peers.filter(p => p.kind !== 'vpn');
|
|
$('peerpanel').classList.toggle('hidden', !peers.length && !s.vpnServers.total);
|
|
$('peercount').textContent = devices.filter(p => p.online).length + ' online of ' + devices.length;
|
|
$('peervpn-row').classList.toggle('hidden', !s.vpnServers.total);
|
|
$('peervpn-text').textContent = 'Show VPN exit servers (' + s.vpnServers.total + ')';
|
|
renderPeers();
|
|
|
|
// Game streaming. The rows are only rebuilt from the daemon's state while
|
|
// the user is not in the middle of editing them.
|
|
$('streampanel').classList.toggle('hidden', s.state !== 'Running' && !s.sunshineHosts.length);
|
|
$('peernames').replaceChildren(...devices.map(p => { const o = document.createElement('option'); o.value = p.name; return o; }));
|
|
$('reachpanel').classList.toggle('hidden', s.state !== 'Running' && !s.userForwards.length);
|
|
const fwdsNow = JSON.stringify(s.userForwards);
|
|
if (!fwdsDirty && fwdsNow !== lastFwds) {
|
|
lastFwds = fwdsNow;
|
|
$('fwdrows').replaceChildren(...s.userForwards.map(fwdRow));
|
|
}
|
|
const hostsNow = JSON.stringify(s.sunshineHosts);
|
|
if (!hostsDirty && hostsNow !== lastHosts) {
|
|
lastHosts = hostsNow;
|
|
$('hostrows').replaceChildren(...s.sunshineHosts.map(h => hostRow(h.host, h.port)));
|
|
}
|
|
$('sunshine-state').textContent = s.sunshineHosts.length
|
|
? s.sunshineHosts.map(h => h.host + ': add ' + h.address + ' in Moonlight').join('. ') + '.'
|
|
: 'No Sunshine host is forwarded.';
|
|
|
|
// Devices to wake.
|
|
$('wakepanel').classList.toggle('hidden', s.state !== 'Running' && !s.wake.length);
|
|
const wakeNow = JSON.stringify(s.wake);
|
|
if (!wakeDirty && wakeNow !== lastWake) {
|
|
lastWake = wakeNow;
|
|
$('wakerows').replaceChildren(...s.wake.map(wakeRow));
|
|
}
|
|
|
|
// Before the first login, say what to expect; afterwards the note stays
|
|
// folded away unless it was opened.
|
|
if (needLogin && !aboutShown) { aboutShown = true; $('aboutbox').open = true; }
|
|
|
|
$('btn-lock').classList.toggle('hidden', !s.passwordSet);
|
|
|
|
refreshFiles();
|
|
|
|
if ($('logbox').open) {
|
|
try { $('logs').textContent = await (await api('/api/logs')).text(); } catch (e) {}
|
|
}
|
|
}
|
|
|
|
async function act(path, confirmText, body) {
|
|
if (confirmText && !confirm(confirmText)) return;
|
|
show('actionmsg', ''); show('actionok', '');
|
|
try {
|
|
const options = {method: 'POST'};
|
|
if (body !== undefined) { options.body = JSON.stringify(body); options.headers = {'Content-Type': 'application/json'}; }
|
|
const r = await api(path, options);
|
|
const text = (await r.text()).trim();
|
|
if (!r.ok) throw new Error(text || r.statusText);
|
|
return text;
|
|
} catch (e) {
|
|
if (e.message !== 'locked') show('actionmsg', e.message);
|
|
} finally {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
// Received files. Asked for separately from the status, and only redrawn
|
|
// when the list changed.
|
|
let shownFiles = '';
|
|
function fileSize(n) {
|
|
if (n < 1024) return n + ' B';
|
|
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
let i = -1;
|
|
do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1);
|
|
return (n < 10 ? n.toFixed(1) : Math.round(n)) + ' ' + units[i];
|
|
}
|
|
async function refreshFiles() {
|
|
let f;
|
|
try { f = await (await api('/api/files')).json(); } catch (e) { return; }
|
|
const key = JSON.stringify(f);
|
|
if (key === shownFiles) return;
|
|
shownFiles = key;
|
|
$('filespanel').classList.toggle('hidden', !f.files.length);
|
|
$('filescount').textContent = f.files.length;
|
|
$('filesdir').textContent = f.dir;
|
|
$('files').replaceChildren(...f.files.map(file => {
|
|
const tr = document.createElement('tr');
|
|
const name = document.createElement('td');
|
|
const a = document.createElement('a');
|
|
a.href = '/api/files/get?name=' + encodeURIComponent(file.name); a.textContent = file.name; a.download = file.name;
|
|
name.append(a);
|
|
const size = document.createElement('td'); size.textContent = fileSize(file.size);
|
|
const when = document.createElement('td'); when.textContent = new Date(file.time).toLocaleString(undefined, {dateStyle: 'medium', timeStyle: 'short'});
|
|
size.style.whiteSpace = when.style.whiteSpace = 'nowrap';
|
|
tr.append(name, size, when);
|
|
return tr;
|
|
}));
|
|
}
|
|
|
|
let updateRefused = ''; // why the daemon would not start an update
|
|
let autoloadWarning = '';
|
|
$('btn-update').onclick = async () => {
|
|
const v = $('btn-update').textContent.replace('Install ', '');
|
|
if (!confirm('Download and install version ' + v + '?\n\nTailscale on this PS5 restarts, which interrupts a stream or Remote Play session that runs through it.\n\n' + autoloadWarning)) return;
|
|
updateRefused = '';
|
|
try {
|
|
const r = await api('/api/update', {method: 'POST'});
|
|
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
|
} catch (e) {
|
|
if (e.message !== 'locked') updateRefused = e.message;
|
|
}
|
|
refresh();
|
|
};
|
|
|
|
$('btn-login').onclick = () => act('/api/login');
|
|
$('btn-logout').onclick = () => act('/api/logout', 'Log this PS5 out of your tailnet?');
|
|
$('btn-quit').onclick = () => act('/api/quit', 'Stop Tailscale on this PS5? Send the payload again to start it.');
|
|
$('btn-uninstall').onclick = async () => {
|
|
const text = await act('/api/uninstall',
|
|
'Remove Tailscale from this PS5?\n\nThis removes the home screen icon, logs the console out of your tailnet, deletes its settings and logs, and stops Tailscale.');
|
|
if (text) show('actionok', text);
|
|
};
|
|
$('btn-lock').onclick = async () => { await act('/api/lock'); showLocked(true); };
|
|
|
|
$('lockform').onsubmit = async ev => {
|
|
ev.preventDefault();
|
|
show('lockmsg', '');
|
|
const r = await fetch('/api/auth', {
|
|
method: 'POST',
|
|
headers: {'X-PS5-Tailscale': '1', 'Content-Type': 'application/json'},
|
|
body: JSON.stringify({password: $('lockpw').value}),
|
|
});
|
|
if (!r.ok) { show('lockmsg', r.status === 403 ? 'Wrong password.' : (await r.text()).trim()); return; }
|
|
$('lockpw').value = '';
|
|
refresh();
|
|
};
|
|
|
|
// Forwards to tailnet devices.
|
|
$('btn-addfwd').onclick = () => { $('fwdrows').append(fwdRow(null)); fwdsDirty = true; };
|
|
$('btn-fwds').onclick = async () => {
|
|
show('fwdmsg', ''); show('fwdok', '');
|
|
const rows = [...$('fwdrows').children].map(w => ({
|
|
host: w.querySelector('.host').value.trim(), port: parseInt(w.querySelector('.port').value, 10) || 0,
|
|
proto: w.querySelector('select').value, listen: w.dataset.listen,
|
|
})).filter(r => r.host || r.port);
|
|
for (const r of rows) {
|
|
if (!r.host || !(r.port >= 1 && r.port <= 65535)) { show('fwdmsg', 'Each line needs a device and a port from 1 to 65535.'); return; }
|
|
}
|
|
// Ports on the console that are spoken for: rows that keep their address.
|
|
const taken = new Set(rows.filter(r => r.listen).map(r => r.proto + r.listen.slice(r.listen.lastIndexOf(':') + 1)));
|
|
const rules = rows.map(r => ({
|
|
proto: r.proto,
|
|
listen: r.listen || '127.0.0.1:' + localPortFor(r.port, r.proto, taken),
|
|
target: (r.host.includes(':') ? '[' + r.host + ']' : r.host) + ':' + r.port,
|
|
}));
|
|
try {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const resp = await api('/api/forwards', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(rules)});
|
|
const text = (await resp.text()).trim();
|
|
if (resp.ok) break;
|
|
// A port on the console that something else uses: if it was picked
|
|
// here, pick the next one and try again.
|
|
const takenPort = resp.headers.get('X-Port-Taken');
|
|
const n = rules.findIndex((r, k) => !rows[k].listen && r.proto + ' ' + r.listen === takenPort);
|
|
if (resp.status !== 409 || n < 0 || attempt >= 30) throw new Error(text || resp.statusText);
|
|
rules[n].listen = '127.0.0.1:' + localPortFor(parseInt(rules[n].listen.split(':').pop(), 10) + 1, rules[n].proto, taken);
|
|
}
|
|
show('fwdok', rules.length ? 'Saved. Use the addresses shown on the PS5.' : 'Saved.');
|
|
} catch (e) {
|
|
if (e.message !== 'locked') show('fwdmsg', e.message);
|
|
}
|
|
fwdsDirty = false; lastFwds = '';
|
|
refresh();
|
|
};
|
|
|
|
// Devices to wake. A row can be woken once it has been saved.
|
|
let wakeDirty = false;
|
|
let lastWake = '';
|
|
let aboutShown = false;
|
|
function wakeRow(t) {
|
|
const div = document.createElement('div');
|
|
div.className = 'hostrow'; div.style.flexWrap = 'wrap';
|
|
const n = document.createElement('input');
|
|
n.type = 'text'; n.className = 'host'; n.placeholder = 'Name, for example gaming-pc'; n.value = t ? t.name : '';
|
|
n.setAttribute('list', 'peernames'); n.setAttribute('aria-label', 'Name'); n.autocomplete = 'off'; n.maxLength = 64;
|
|
const m = document.createElement('input');
|
|
m.type = 'text'; m.className = 'mac'; m.placeholder = '00:11:22:AA:BB:CC'; m.value = t ? t.mac : '';
|
|
m.setAttribute('aria-label', 'MAC address'); m.autocomplete = 'off'; m.spellcheck = false;
|
|
const w = document.createElement('button');
|
|
w.type = 'button'; w.textContent = 'Wake'; w.disabled = !t;
|
|
w.title = t ? 'Send the wake-up packet' : 'Save first';
|
|
w.onclick = async () => {
|
|
show('wakemsg', ''); show('wakeok', '');
|
|
try {
|
|
const r = await api('/api/wake?mac=' + encodeURIComponent(t.mac), {method: 'POST'});
|
|
const text = (await r.text()).trim();
|
|
if (!r.ok) throw new Error(text || r.statusText);
|
|
show('wakeok', text);
|
|
} catch (e) {
|
|
if (e.message !== 'locked') show('wakemsg', e.message);
|
|
}
|
|
};
|
|
const x = document.createElement('button');
|
|
x.type = 'button'; x.className = 'small'; x.textContent = 'Remove';
|
|
x.onclick = () => { div.remove(); wakeDirty = true; };
|
|
n.oninput = () => { wakeDirty = true; };
|
|
m.oninput = () => { wakeDirty = true; w.disabled = true; w.title = 'Save first'; };
|
|
div.append(n, m, w, x);
|
|
return div;
|
|
}
|
|
$('btn-addwake').onclick = () => { $('wakerows').append(wakeRow(null)); wakeDirty = true; };
|
|
$('btn-wakes').onclick = async () => {
|
|
show('wakemsg', ''); show('wakeok', '');
|
|
const list = [...$('wakerows').children].map(d => ({name: d.querySelector('.host').value.trim(), mac: d.querySelector('.mac').value.trim()}))
|
|
.filter(t => t.name || t.mac);
|
|
try {
|
|
const r = await api('/api/wakelist', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(list)});
|
|
const text = (await r.text()).trim();
|
|
if (!r.ok) throw new Error(text || r.statusText);
|
|
show('wakeok', 'Saved.');
|
|
wakeDirty = false; lastWake = '';
|
|
} catch (e) {
|
|
if (e.message !== 'locked') show('wakemsg', e.message);
|
|
}
|
|
refresh();
|
|
};
|
|
|
|
// The diagnostics file is a plain download; the session cookie covers it.
|
|
$('btn-diag').onclick = () => { location.href = '/api/diagnostics'; };
|
|
|
|
// Game streaming hosts.
|
|
$('btn-addhost').onclick = () => { $('hostrows').append(hostRow('', 0)); hostsDirty = true; };
|
|
$('btn-sunshine').onclick = async () => {
|
|
const hosts = [];
|
|
for (const div of $('hostrows').children) {
|
|
const host = div.querySelector('.host').value.trim();
|
|
const port = parseInt(div.querySelector('.port').value, 10) || 0;
|
|
if (host) hosts.push({host: host, port: port});
|
|
}
|
|
hostsDirty = false; lastHosts = '';
|
|
await act('/api/sunshine', '', hosts);
|
|
};
|
|
|
|
// Settings.
|
|
const ports = text => text.split(/[\s,]+/).filter(x => x).map(x => parseInt(x, 10));
|
|
async function loadSettings() {
|
|
let c;
|
|
try { c = await (await api('/api/config')).json(); } catch (e) { return; }
|
|
$('set-hostname').value = c.hostname;
|
|
$('set-pwstate').textContent = c.passwordSet ? '(set)' : '(not set)';
|
|
$('set-password').value = '';
|
|
$('set-pwremove').checked = false;
|
|
$('set-pwremove-row').classList.toggle('hidden', !c.passwordSet);
|
|
$('set-udp').value = c.udpPorts.join(', ');
|
|
$('set-blocked').value = c.blockedPorts.join(', ');
|
|
$('set-proxy').value = c.httpProxyAddr;
|
|
$('set-priority').value = c.priority;
|
|
$('set-allowfrom').value = c.allowFrom;
|
|
$('set-receivedir').value = c.receiveDir;
|
|
$('set-payloadpath').value = c.payloadPath;
|
|
$('set-webaddr').value = c.webAddr;
|
|
$('set-updates').checked = c.checkUpdates;
|
|
$('set-verbose').checked = c.verbose;
|
|
}
|
|
$('settingsbox').addEventListener('toggle', () => { if ($('settingsbox').open) loadSettings(); });
|
|
if (location.hash === '#settings') $('settingsbox').open = true;
|
|
$('settingsform').onsubmit = async ev => {
|
|
ev.preventDefault();
|
|
show('settingsmsg', ''); show('settingsok', '');
|
|
const udp = ports($('set-udp').value), blocked = ports($('set-blocked').value);
|
|
if (udp.concat(blocked).some(p => !(p >= 1 && p <= 65535))) { show('settingsmsg', 'Ports must be numbers from 1 to 65535.'); return; }
|
|
const c = {
|
|
hostname: $('set-hostname').value.trim(),
|
|
webAddr: $('set-webaddr').value.trim(),
|
|
httpProxyAddr: $('set-proxy').value.trim(),
|
|
udpPorts: udp,
|
|
blockedPorts: blocked,
|
|
priority: $('set-priority').value,
|
|
allowFrom: $('set-allowfrom').value,
|
|
receiveDir: $('set-receivedir').value.trim(),
|
|
payloadPath: $('set-payloadpath').value.trim(),
|
|
checkUpdates: $('set-updates').checked,
|
|
verbose: $('set-verbose').checked,
|
|
};
|
|
if ($('set-pwremove').checked) c.password = '';
|
|
else if ($('set-password').value) c.password = $('set-password').value;
|
|
try {
|
|
const r = await api('/api/config', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(c)});
|
|
if (!r.ok) throw new Error((await r.text()).trim() || r.statusText);
|
|
const reply = await r.json();
|
|
let text = 'Saved.';
|
|
if (reply.restart && reply.restart.length) text += ' Takes effect when Tailscale is started again: ' + reply.restart.join(', ') + '.';
|
|
show('settingsok', text);
|
|
if (reply.problems && reply.problems.length) show('settingsmsg', reply.problems.join(' '));
|
|
loadSettings();
|
|
refresh();
|
|
} catch (e) {
|
|
if (e.message !== 'locked') show('settingsmsg', e.message);
|
|
}
|
|
};
|
|
|
|
$('logbox').addEventListener('toggle', refresh);
|
|
|
|
// The device list can be long. Remember whether it was left collapsed and
|
|
// how it was filtered; the browser may not allow storage, in which case it
|
|
// simply starts open and unfiltered.
|
|
if (stored('peersCollapsed') === '1') $('peerbox').open = false;
|
|
$('peerbox').addEventListener('toggle', () => store('peersCollapsed', $('peerbox').open ? '0' : '1'));
|
|
$('peeronline').checked = stored('peersOnlineOnly') === '1';
|
|
$('peervpn').checked = stored('peersShowVPN') === '1';
|
|
$('peersearch').oninput = renderPeers;
|
|
$('peeronline').onchange = () => { store('peersOnlineOnly', $('peeronline').checked ? '1' : '0'); renderPeers(); };
|
|
$('peervpn').onchange = () => { store('peersShowVPN', $('peervpn').checked ? '1' : '0'); refresh(); };
|
|
|
|
refresh();
|
|
setInterval(refresh, 3000);
|
|
</script>
|
|
</body>
|
|
</html>
|