Files
holdmysocks--ps5-tailscale/tsd/status.html
T
holdmysocks 02fc73a04d Receive files with Taildrop; install signed updates from the status page
Taildrop: files sent to the console from the user's other devices are moved
from Tailscale's holding area to /data/tailscale/received (a setting),
announced on screen and listed on the status page with download links.

Updates: the status page can install a newer release when asked to. A
release carries tailscale.elf.sig, an Ed25519 signature over its version
and SHA-256; the daemon installs only what verifies against the public key
built into it, is the version the release claims and is newer than itself.
The payload is handed to the ELF loader, and the copy named by the new
payloadPath setting is replaced as well.

- tsd/relsig, tsd/cmd/signrelease: signing, verifying, and keeping the key
  (keygen, passphrase-protected backup and restore).
- tools/make-release.ps1 builds, signs and checksums a release.
- Files are no longer copied to sockets with sendfile, which the PS5
  kernel refuses.
2026-10-05 08:52:31 -04:00

711 lines
33 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; }
}
.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; }
.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="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 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="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">
<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">Extra forwards from the console to tailnet hosts
<textarea id="set-forwards" spellcheck="false"></textarea>
<span class="hint">One per line: <code>tcp 127.0.0.1:8096 my-nas:8096</code> (protocol, local address, tailnet host and port).</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 of <code>tailscale.elf</code> 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. 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-lock" class="hidden">Lock this page</button>
</div>
<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 = '';
function hostRow(host, port) {
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 = 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; };
div.append(h, p, x);
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));
}
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(', '));
for (const [v, mono, note] of [[p.name, false, notes.join(' · ')], [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 (note) { const n = document.createElement('span'); n.className = 'tag'; n.textContent = note; 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);
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);
}
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');
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]);
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.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; }));
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.';
$('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
$('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.')) 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();
};
// 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-forwards').value = c.forwards.map(f => f.proto + ' ' + f.listen + ' ' + f.target).join('\n');
$('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 forwards = [];
for (const line of $('set-forwards').value.split('\n').map(l => l.trim()).filter(l => l)) {
const parts = line.split(/\s+/);
if (parts.length !== 3) { show('settingsmsg', 'Each forward needs three parts: protocol, local address, target. Problem: ' + line); return; }
forwards.push({proto: parts[0].toLowerCase(), listen: parts[1], target: parts[2]});
}
const c = {
hostname: $('set-hostname').value.trim(),
webAddr: $('set-webaddr').value.trim(),
httpProxyAddr: $('set-proxy').value.trim(),
forwards: forwards,
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>