mirror of
https://github.com/holdmysocks/ps5-tailscale.git
synced 2026-10-06 14:00:18 +02:00
A tailnet with a VPN add-on has hundreds of exit servers among its peers, which buried the real devices on the status page. - Devices are grouped: this tailnet, shared with you, VPN exit servers. - VPN exit servers are counted but only listed, and only sent to the page, when asked for. - Search by name, address, OS, tag or place, and an online-only toggle. - Devices that offer exit-node service are marked. - The count and the streaming host suggestions leave exit servers out.
543 lines
25 KiB
HTML
543 lines
25 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); }
|
|
.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>
|
|
</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="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="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">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">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);
|
|
}
|
|
|
|
function row(dl, name, value, mono) {
|
|
const dt = document.createElement('dt'); dt.textContent = name;
|
|
const dd = document.createElement('dd');
|
|
if (mono) { const c = document.createElement('code'); c.textContent = value; dd.append(c); }
|
|
else dd.textContent = value;
|
|
dl.append(dt, dd);
|
|
}
|
|
|
|
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) { const c = document.createElement('code'); c.textContent = v; td.append(c); } 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;
|
|
|
|
$('updatepanel').classList.toggle('hidden', !s.latestVersion);
|
|
if (s.latestVersion) {
|
|
$('updatetext').textContent = 'Version ' + s.latestVersion + ' is available. ';
|
|
$('updatelink').href = s.updateURL;
|
|
}
|
|
|
|
const dl = $('facts');
|
|
dl.replaceChildren();
|
|
row(dl, 'Name', s.dnsName || s.hostname);
|
|
if (s.ips.length) row(dl, 'Tailnet address', s.ips.join(', '), true);
|
|
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)' : ''));
|
|
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);
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
$('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-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,
|
|
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>
|