Files
holdmysocks e4986b4d52 Wake-on-LAN, a diagnostics download, connection tests and a note on what to expect
- "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.
2026-10-06 19:46:22 -04:00

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>