Add one-time setup links as an alternative to the QR code
A config can now be handed over as a one-time link, valid for 1 h, 24 h or 7 days and protected by a PIN by default. Keys are made only when the link is opened; the link works once and is revoked after 5 wrong PINs. Issuing a new config offers the same choice, and the current config keeps working until the link is used. Remove the option to paste a client's public key, in the web UI, the API and the iOS app.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
// The page a setup link opens. It works without signing in: the token in
|
||||
// the URL is the credential. The config exists only in this page's memory;
|
||||
// leaving the page loses it, as the link works once.
|
||||
(() => {
|
||||
const app = document.getElementById('app');
|
||||
const token = location.pathname.split('/').pop();
|
||||
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else if (['value', 'hidden', 'htmlFor', 'disabled', 'src', 'alt', 'href', 'type', 'id', 'autocomplete', 'inputMode', 'maxLength', 'required'].includes(k)) el[k] = v;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
||||
return el;
|
||||
}
|
||||
|
||||
// Same drawing as favicon.svg.
|
||||
function logo(size, plain) {
|
||||
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
||||
s.innerHTML = (plain ? '' : '<rect width="64" height="64" rx="14" fill="#1b1b1d"/><rect x="0.5" y="0.5" width="63" height="63" rx="13.5" fill="none" stroke="#fff" stroke-opacity="0.2"/>') +
|
||||
'<circle cx="32" cy="32" r="22" fill="none" stroke="#fff" stroke-width="3.5"/><g transform="translate(32 33) scale(0.66) translate(-32 -33)">' +
|
||||
'<path d="M18 50V30a14 14 0 0 1 28 0v20l-4.7-4-4.6 4-4.7-4-4.7 4-4.6-4z" fill="#fff"/>' +
|
||||
(plain ? '' : '<circle cx="27" cy="30" r="3.2" fill="#c8372d"/><circle cx="37" cy="30" r="3.2" fill="#c8372d"/>') + '</g>';
|
||||
return s;
|
||||
}
|
||||
|
||||
const fmtExpiry = (iso) => new Date(iso).toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
async function call(method, body) {
|
||||
const opt = { method, headers: {} };
|
||||
if (body) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
opt.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch('/api/v1/setup/' + encodeURIComponent(token), opt);
|
||||
let data = {};
|
||||
try { data = await r.json(); } catch { /* empty */ }
|
||||
return { status: r.status, data };
|
||||
}
|
||||
|
||||
function show(...kids) { app.replaceChildren(h('div', { class: 'setupbox' }, kids)); }
|
||||
|
||||
function invalid() {
|
||||
app.replaceChildren(h('div', { class: 'setupbox center' },
|
||||
h('span', { class: 'ghost' }, logo(72, true)),
|
||||
h('h1', null, 'This link isn\'t valid'),
|
||||
h('p', null, 'It was already used, it expired, or it was revoked. Ask whoever sent it for a new one.'),
|
||||
h('p', { class: 'loginfoot' }, 'GHOSTWIRE')));
|
||||
}
|
||||
|
||||
function start(info) {
|
||||
const err = h('p', { class: 'err-text', role: 'alert' });
|
||||
const pin = info.pinRequired
|
||||
? h('input', { id: 'pin', class: 'pin', inputMode: 'numeric', autocomplete: 'one-time-code', maxLength: 8, required: true })
|
||||
: null;
|
||||
const btn = h('button', { type: 'submit', class: 'btn primary' }, info.pinRequired ? 'Continue' : 'Get my VPN profile');
|
||||
const form = h('form', { class: 'loginform', onSubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
err.textContent = '';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const { status, data } = await call('POST', { pin: pin ? pin.value.trim() : '' });
|
||||
if (status === 200) return ready(data);
|
||||
if (status === 404) return invalid();
|
||||
err.textContent = data.error || 'Something went wrong. Try again.';
|
||||
if (pin) pin.select();
|
||||
} catch {
|
||||
err.textContent = 'No connection to the server. Try again.';
|
||||
}
|
||||
btn.disabled = false;
|
||||
} },
|
||||
pin ? h('div', { class: 'field' }, h('label', { htmlFor: 'pin' }, 'PIN'), pin) : null,
|
||||
err, btn);
|
||||
app.replaceChildren(h('div', { class: 'setupbox' },
|
||||
h('div', { class: 'brand stack' }, logo(56), h('div', { class: 'wm' }, 'GHOSTWIRE')),
|
||||
h('div', { class: 'center' },
|
||||
h('h1', null, 'Set up your VPN'),
|
||||
h('p', null, 'This link adds the VPN profile ', h('strong', { class: 'mono' }, info.name), ' to your device.',
|
||||
info.pinRequired ? ' Enter the PIN you were given.' : '')),
|
||||
form,
|
||||
h('p', { class: 'note' }, 'The link works once and expires ' + fmtExpiry(info.expires) + '.')));
|
||||
(pin || btn).focus();
|
||||
}
|
||||
|
||||
function ready(res) {
|
||||
const file = res.name + '.conf';
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([res.config], { type: 'application/octet-stream' }));
|
||||
const a = h('a', { href: url, download: file });
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
const qr = h('img', { class: 'qr', src: res.qr, alt: 'QR code of the VPN profile ' + res.name, hidden: true });
|
||||
const qrBtn = h('button', { type: 'button', class: 'btn', onClick: () => { qr.hidden = !qr.hidden; qrBtn.textContent = qr.hidden ? 'Show QR code' : 'Hide QR code'; } }, 'Show QR code');
|
||||
const step = (n, title, ...body) => h('li', null, h('span', { class: 'num' }, String(n)), h('div', null, h('strong', null, title), body));
|
||||
// Leaving the page loses the only copy of the private key.
|
||||
window.addEventListener('beforeunload', (e) => e.preventDefault());
|
||||
show(
|
||||
h('div', { class: 'brand' }, logo(32), h('div', { class: 'wm' }, 'GHOSTWIRE')),
|
||||
h('h1', null, 'Your VPN profile is ready'),
|
||||
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
||||
h('ol', { class: 'steps' },
|
||||
step(1, 'Install WireGuard',
|
||||
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
|
||||
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
|
||||
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
|
||||
step(2, 'Add the profile',
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
||||
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
||||
step(3, 'Setting up another device?',
|
||||
qrBtn, qr,
|
||||
h('p', null, 'Opened this on a computer? Scan the QR code with WireGuard on your phone.'))));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { status, data } = await call('GET');
|
||||
if (status === 200) start(data);
|
||||
else invalid();
|
||||
} catch {
|
||||
show(h('h1', null, 'No connection'), h('p', null, 'The server can\'t be reached. Reload the page to try again.'));
|
||||
}
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user