2fb067ecbf
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
natlab-integrationtest / natlab-integrationtest (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (benchmarks) (push) Has been cancelled
CI / Windows (1/2) (push) Has been cancelled
CI / Windows (2/2) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / fuzz (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (macOS) (push) Has been cancelled
CI / staticcheck (Linux) (push) Has been cancelled
CI / staticcheck (Windows) (push) Has been cancelled
CI / staticcheck (Portable (1/4)) (push) Has been cancelled
CI / staticcheck (Portable (2/4)) (push) Has been cancelled
CI / staticcheck (Portable (3/4)) (push) Has been cancelled
CI / staticcheck (Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
test installer.sh / test (curl, alpine:3.21) (push) Has been cancelled
test installer.sh / test (curl, alpine:edge) (push) Has been cancelled
test installer.sh / test (curl, alpine:latest) (push) Has been cancelled
test installer.sh / test (curl, amazonlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, archlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:sid-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, debian:testing-slim) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:stable) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:unstable) (push) Has been cancelled
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-dev) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-rolling) (push) Has been cancelled
test installer.sh / test (curl, opensuse/leap:latest) (push) Has been cancelled
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:8) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:9) (push) Has been cancelled
test installer.sh / test (curl, parrotsec/core:latest) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:8.7) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:9) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:20.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:22.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Has been cancelled
test installer.sh / test (wget, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (wget, debian:sid-slim) (push) Has been cancelled
update-flake / update-flake (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
test installer.sh / notify-slack (push) Has been cancelled
Client security fixes (cmd/tailscale-tray/main.go): - SSRF protection in Add Server dialog (validateControlURL): reject private/loopback/link-local/cloud-metadata IPs via DNS resolution - RCE gate on AuthURL/BrowseToURL exec paths (validateAuthURL) - Sanitized URL logging (sanitizeURLForLog drops query auth tokens) - Error handling on exec.Command with user-facing showError() Admin panel security (web-admin): - Bcrypt password hashing (replaces SHA256) - Rate limiting: 5 failed logins → 15-min lockout - Session + login attempt cleanup goroutine (hourly) - url.QueryEscape / encodeURIComponent for all API params - Fail-hard startup when no TLS and non-loopback bind - ADMIN_PASSWORD required (no default), password min 12 chars - Username regex whitelist Installer hardening (Setup.wxs): - util:PermissionEx restricts SCM access: only Administrators + SYSTEM can start/stop/reconfigure service. Authenticated Users limited to QueryStatus/QueryConfig/Interrogate - Vital="yes" on ServiceInstall Docs & roadmap: - PRODUCTION_ROADMAP.md: 5-milestone plan (security + features + distribution + ops) with granular tasks, effort, done-when - CLIENT_SECURITY_AUDIT.md, SECURITY_FIXES.md, DEPLOYMENT.md - AI assistant rules (.cursorrules, .antigravityrules, etc.) Build & distribution: - build-msi.ps1, deploy-and-sign.ps1, sign-release.ps1 - redeploy.ps1, tray-deploy.ps1, test-msi.ps1 - installer/msi/ alternative WXS setup - Restored .github/workflows/ removed in mirror cleanup .gitignore hardened: *.pfx, *.p12, *.key, *.pem, .env*
739 lines
26 KiB
JavaScript
739 lines
26 KiB
JavaScript
// Tailscale Custom - Web Admin Frontend
|
|
|
|
let authToken = '';
|
|
let currentUser = { username: '', role: '' };
|
|
let nodesData = [];
|
|
let usersData = [];
|
|
let accountsData = [];
|
|
let refreshTimer = null;
|
|
|
|
// ==================== Auth ====================
|
|
|
|
async function doLogin() {
|
|
const username = document.getElementById('login-username').value.trim();
|
|
const password = document.getElementById('login-password').value;
|
|
const errEl = document.getElementById('login-error');
|
|
errEl.style.display = 'none';
|
|
|
|
if (!username || !password) {
|
|
errEl.textContent = 'Please enter username and password';
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.ok) {
|
|
errEl.textContent = data.error || 'Login failed';
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
authToken = data.token;
|
|
currentUser = { username: data.username, role: data.role };
|
|
sessionStorage.setItem('authToken', authToken);
|
|
sessionStorage.setItem('authUser', JSON.stringify(currentUser));
|
|
enterApp();
|
|
} catch (e) {
|
|
errEl.textContent = 'Connection error';
|
|
errEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function doLogout() {
|
|
api('api/auth/logout', 'POST').catch(() => {});
|
|
authToken = '';
|
|
currentUser = { username: '', role: '' };
|
|
sessionStorage.removeItem('authToken');
|
|
sessionStorage.removeItem('authUser');
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
document.getElementById('main-app').style.display = 'none';
|
|
document.getElementById('login-screen').style.display = 'flex';
|
|
document.getElementById('login-password').value = '';
|
|
}
|
|
|
|
function enterApp() {
|
|
document.getElementById('login-screen').style.display = 'none';
|
|
document.getElementById('main-app').style.display = 'flex';
|
|
|
|
// Update user display
|
|
document.getElementById('user-display').textContent = currentUser.username;
|
|
document.getElementById('user-avatar').textContent = currentUser.username.charAt(0).toUpperCase();
|
|
const badge = document.getElementById('user-role-badge');
|
|
badge.textContent = currentUser.role;
|
|
badge.className = 'role-badge role-' + currentUser.role;
|
|
|
|
// Show/hide tabs based on role
|
|
const isAdmin = currentUser.role === 'admin';
|
|
document.querySelectorAll('.admin-only').forEach(el => el.style.display = isAdmin ? '' : 'none');
|
|
document.querySelectorAll('.user-only').forEach(el => el.style.display = isAdmin ? 'none' : '');
|
|
|
|
// Activate first visible tab
|
|
const firstTab = document.querySelector('.nav-item[style=""], .nav-item:not([style])');
|
|
if (firstTab) switchTab(firstTab.dataset.tab);
|
|
|
|
// Auto-refresh
|
|
refreshAll();
|
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
refreshTimer = setInterval(refreshAll, 30000);
|
|
}
|
|
|
|
// ==================== API helper ====================
|
|
|
|
async function api(url, method = 'GET', body = null) {
|
|
const opts = {
|
|
method,
|
|
headers: { 'Authorization': 'Bearer ' + authToken, 'Content-Type': 'application/json' }
|
|
};
|
|
if (body) opts.body = JSON.stringify(body);
|
|
const resp = await fetch(url, opts);
|
|
if (resp.status === 401) {
|
|
doLogout();
|
|
throw new Error('Session expired');
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
// ==================== Tab switching ====================
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.nav-item');
|
|
if (item) switchTab(item.dataset.tab);
|
|
});
|
|
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.tab === tab));
|
|
document.querySelectorAll('.tab-content').forEach(el => el.style.display = el.id === 'tab-' + tab ? '' : 'none');
|
|
|
|
// Refresh data for this tab
|
|
switch (tab) {
|
|
case 'dashboard': refreshDashboard(); break;
|
|
case 'nodes': refreshNodes(); break;
|
|
case 'accounts': refreshAccounts(); break;
|
|
case 'users': refreshUsers(); break;
|
|
case 'keys': refreshKeys(); break;
|
|
case 'my-nodes': refreshMyNodes(); break;
|
|
case 'download': refreshDownloads(); break;
|
|
}
|
|
}
|
|
|
|
// ==================== Refresh all ====================
|
|
|
|
function refreshAll() {
|
|
if (currentUser.role === 'admin') {
|
|
refreshDashboard();
|
|
refreshNodes();
|
|
} else {
|
|
refreshMyNodes();
|
|
}
|
|
}
|
|
|
|
// ==================== Dashboard (admin) ====================
|
|
|
|
async function refreshDashboard() {
|
|
try {
|
|
const [nodesResp, usersResp] = await Promise.all([
|
|
api('api/admin/nodes'),
|
|
api('api/admin/users')
|
|
]);
|
|
const nodesJson = await nodesResp.json();
|
|
const usersJson = await usersResp.json();
|
|
const nodes = nodesJson.nodes || [];
|
|
const users = usersJson.users || [];
|
|
|
|
const online = nodes.filter(n => isOnline(n)).length;
|
|
const total = nodes.length;
|
|
|
|
document.getElementById('stats-grid').innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-value">${total}</div>
|
|
<div class="stat-label">Total Nodes</div>
|
|
</div>
|
|
<div class="stat-card stat-online">
|
|
<div class="stat-value">${online}</div>
|
|
<div class="stat-label">Online</div>
|
|
</div>
|
|
<div class="stat-card stat-offline">
|
|
<div class="stat-value">${total - online}</div>
|
|
<div class="stat-label">Offline</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${users.length}</div>
|
|
<div class="stat-label">Users</div>
|
|
</div>
|
|
`;
|
|
} catch (e) { console.error('dashboard', e); }
|
|
}
|
|
|
|
// ==================== Nodes (admin) ====================
|
|
|
|
async function refreshNodes() {
|
|
try {
|
|
const resp = await api('api/admin/nodes');
|
|
const data = await resp.json();
|
|
nodesData = data.nodes || [];
|
|
renderNodes();
|
|
} catch (e) { console.error('nodes', e); }
|
|
}
|
|
|
|
function renderNodes() {
|
|
const tbody = document.querySelector('#nodes-table tbody');
|
|
if (!nodesData.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">No nodes found</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = nodesData.map(n => {
|
|
const on = isOnline(n);
|
|
const ip = (n.ipAddresses || [])[0] || '-';
|
|
const user = n.user?.name || '-';
|
|
const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never';
|
|
const name = n.givenName || n.name || '-';
|
|
return `<tr>
|
|
<td><strong>${esc(name)}</strong></td>
|
|
<td><code>${esc(ip)}</code></td>
|
|
<td>${esc(user)}</td>
|
|
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
|
|
<td>${lastSeen}</td>
|
|
<td>
|
|
<button class="btn btn-xs btn-danger" onclick="deleteNode('${n.id}', '${esc(name)}')">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function deleteNode(id, name) {
|
|
if (!confirm(`Delete node "${name}"?`)) return;
|
|
try {
|
|
await api('api/admin/nodes/' + id, 'DELETE');
|
|
toast('Node deleted');
|
|
refreshNodes();
|
|
refreshDashboard();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Accounts (admin) ====================
|
|
|
|
async function refreshAccounts() {
|
|
try {
|
|
const resp = await api('api/admin/accounts');
|
|
const data = await resp.json();
|
|
accountsData = data.accounts || [];
|
|
renderAccounts();
|
|
} catch (e) { console.error('accounts', e); }
|
|
}
|
|
|
|
function renderAccounts() {
|
|
const tbody = document.querySelector('#accounts-table tbody');
|
|
if (!accountsData.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No accounts</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = accountsData.map(a => {
|
|
const created = a.createdAt ? new Date(a.createdAt).toLocaleString() : '-';
|
|
const isAdmin = a.username === 'admin';
|
|
return `<tr>
|
|
<td><strong>${esc(a.username)}</strong></td>
|
|
<td><span class="role-badge role-${a.role}">${a.role}</span></td>
|
|
<td>${created}</td>
|
|
<td>
|
|
<button class="btn btn-xs btn-outline" onclick="showResetPasswordModal('${esc(a.username)}')">Reset Password</button>
|
|
${isAdmin ? '' : `<button class="btn btn-xs btn-danger" onclick="deleteAccount('${esc(a.username)}')">Delete</button>`}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function deleteAccount(username) {
|
|
if (!confirm(`Delete account "${username}"? This will also delete the Headscale user.`)) return;
|
|
try {
|
|
const resp = await api('api/admin/accounts/' + encodeURIComponent(username), 'DELETE');
|
|
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
|
toast('Account deleted');
|
|
refreshAccounts();
|
|
refreshUsers();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
function showCreateAccountModal() {
|
|
openModal('Create Account', `
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="new-acct-username" placeholder="username">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Password</label>
|
|
<input type="password" id="new-acct-password" placeholder="min 12 characters">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Role</label>
|
|
<select id="new-acct-role">
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary btn-block" onclick="createAccount()">Create</button>
|
|
`);
|
|
}
|
|
|
|
async function createAccount() {
|
|
const username = document.getElementById('new-acct-username').value.trim();
|
|
const password = document.getElementById('new-acct-password').value;
|
|
const role = document.getElementById('new-acct-role').value;
|
|
if (!username || !password) { toast('Fill all fields', true); return; }
|
|
try {
|
|
const resp = await api('api/admin/accounts', 'POST', { username, password, role });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
|
toast('Account created');
|
|
closeModal();
|
|
refreshAccounts();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
function showResetPasswordModal(username) {
|
|
openModal('Reset Password: ' + username, `
|
|
<div class="form-group">
|
|
<label>New Password</label>
|
|
<input type="password" id="reset-password" placeholder="min 12 characters">
|
|
</div>
|
|
<button class="btn btn-primary btn-block" onclick="resetPassword('${esc(username)}')">Reset</button>
|
|
`);
|
|
}
|
|
|
|
async function resetPassword(username) {
|
|
const password = document.getElementById('reset-password').value;
|
|
if (!password) { toast('Enter password', true); return; }
|
|
try {
|
|
const resp = await api('api/admin/accounts/' + encodeURIComponent(username) + '/password', 'POST', { password });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
|
toast('Password reset');
|
|
closeModal();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Headscale Users (admin) ====================
|
|
|
|
async function refreshUsers() {
|
|
try {
|
|
const resp = await api('api/admin/users');
|
|
const data = await resp.json();
|
|
usersData = data.users || [];
|
|
renderUsers();
|
|
} catch (e) { console.error('users', e); }
|
|
}
|
|
|
|
function renderUsers() {
|
|
const tbody = document.querySelector('#users-table tbody');
|
|
if (!usersData.length) {
|
|
tbody.innerHTML = '<tr><td colspan="3" class="empty">No users</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = usersData.map(u => {
|
|
const created = u.createdAt ? new Date(u.createdAt).toLocaleString() : '-';
|
|
return `<tr>
|
|
<td><strong>${esc(u.name)}</strong></td>
|
|
<td>${created}</td>
|
|
<td>
|
|
<button class="btn btn-xs btn-outline" onclick="showResetUserPasswordModal('${esc(u.name)}')">Reset Password</button>
|
|
<button class="btn btn-xs btn-danger" onclick="deleteUser('${esc(u.name)}')">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function showCreateUserModal() {
|
|
openModal('Create Headscale User', `
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="new-hs-user" placeholder="username">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Password (for login account)</label>
|
|
<input type="password" id="new-hs-password" placeholder="min 12 characters">
|
|
</div>
|
|
<p class="help-text">A login account will also be created so this user can access the admin panel.</p>
|
|
<button class="btn btn-primary btn-block" onclick="createUser()">Create</button>
|
|
`);
|
|
}
|
|
|
|
async function createUser() {
|
|
const name = document.getElementById('new-hs-user').value.trim();
|
|
const password = document.getElementById('new-hs-password').value;
|
|
if (!name) { toast('Enter username', true); return; }
|
|
if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; }
|
|
try {
|
|
const resp = await api('api/admin/users', 'POST', { name, password });
|
|
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
|
toast('User created');
|
|
closeModal();
|
|
refreshUsers();
|
|
refreshAccounts();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
async function deleteUser(name) {
|
|
if (!confirm(`Delete Headscale user "${name}"? This will also delete the login account.`)) return;
|
|
try {
|
|
const resp = await api('api/admin/users/' + encodeURIComponent(name), 'DELETE');
|
|
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
|
toast('User deleted');
|
|
refreshUsers();
|
|
refreshAccounts();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
function showResetUserPasswordModal(username) {
|
|
openModal('Reset Password: ' + username, `
|
|
<div class="form-group">
|
|
<label>New Password</label>
|
|
<input type="password" id="reset-user-password" placeholder="min 12 characters">
|
|
</div>
|
|
<button class="btn btn-primary btn-block" onclick="resetUserPassword('${esc(username)}')">Reset</button>
|
|
`);
|
|
}
|
|
|
|
async function resetUserPassword(username) {
|
|
const password = document.getElementById('reset-user-password').value;
|
|
if (!password || password.length < 12) { toast('Password required (min 12 characters)', true); return; }
|
|
try {
|
|
const resp = await api('api/admin/users/' + encodeURIComponent(username) + '/password', 'POST', { password });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
|
toast('Password reset');
|
|
closeModal();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Keys (admin) ====================
|
|
|
|
async function refreshKeys() {
|
|
try {
|
|
// Get keys for all users
|
|
const usersResp = await api('api/admin/users');
|
|
const usersJson = await usersResp.json();
|
|
const users = usersJson.users || [];
|
|
|
|
// Fetch all keys in parallel instead of sequential
|
|
const keyPromises = users.map(u =>
|
|
api('api/admin/preauthkeys?user=' + encodeURIComponent(u.name))
|
|
.then(r => r.json())
|
|
.then(data => ({
|
|
user: u.name,
|
|
keys: data.preAuthKeys || []
|
|
}))
|
|
.catch(() => ({ user: u.name, keys: [] }))
|
|
);
|
|
|
|
const results = await Promise.all(keyPromises);
|
|
let allKeys = [];
|
|
results.forEach(result => {
|
|
result.keys.forEach(k => k._user = result.user);
|
|
allKeys = allKeys.concat(result.keys);
|
|
});
|
|
|
|
renderKeys(allKeys);
|
|
} catch (e) { console.error('keys', e); }
|
|
}
|
|
|
|
function renderKeys(keys) {
|
|
const tbody = document.querySelector('#keys-table tbody');
|
|
if (!keys.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">No keys</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = keys.map(k => {
|
|
const exp = k.expiration ? new Date(k.expiration).toLocaleString() : '-';
|
|
return `<tr>
|
|
<td><code class="key-text">${esc((k.key || '').substring(0, 12))}...</code></td>
|
|
<td>${esc(k._user || k.user || '-')}</td>
|
|
<td>${k.reusable ? '✅' : '❌'}</td>
|
|
<td>${k.ephemeral ? '✅' : '❌'}</td>
|
|
<td>${k.used ? '✅' : '❌'}</td>
|
|
<td>${exp}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function showCreateKeyModal() {
|
|
// Need users list for dropdown
|
|
const usersOpts = usersData.map(u => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('');
|
|
openModal('Create Pre-Auth Key', `
|
|
<div class="form-group">
|
|
<label>User</label>
|
|
<select id="new-key-user">${usersOpts}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" id="new-key-reusable"> Reusable</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" id="new-key-ephemeral"> Ephemeral</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Expiration (hours)</label>
|
|
<input type="number" id="new-key-expiry" value="24" min="1">
|
|
</div>
|
|
<button class="btn btn-primary btn-block" onclick="createKey()">Create</button>
|
|
`);
|
|
}
|
|
|
|
async function createKey() {
|
|
const user = document.getElementById('new-key-user').value;
|
|
const reusable = document.getElementById('new-key-reusable').checked;
|
|
const ephemeral = document.getElementById('new-key-ephemeral').checked;
|
|
const hours = parseInt(document.getElementById('new-key-expiry').value) || 24;
|
|
const expiration = new Date(Date.now() + hours * 3600000).toISOString();
|
|
|
|
try {
|
|
const resp = await api('api/admin/preauthkeys', 'POST', { user, reusable, ephemeral, expiration });
|
|
if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; }
|
|
toast('Key created');
|
|
closeModal();
|
|
refreshKeys();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Register Node (admin) ====================
|
|
|
|
async function showRegisterModal() {
|
|
if (!usersData.length) {
|
|
try {
|
|
const resp = await api('api/admin/users');
|
|
const data = await resp.json();
|
|
usersData = data.users || [];
|
|
} catch (e) { console.error('load users', e); }
|
|
}
|
|
const usersOpts = usersData.length
|
|
? usersData.map(u => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('')
|
|
: '<option value="">No users</option>';
|
|
openModal('Register Node', `
|
|
<div class="form-group">
|
|
<label>User</label>
|
|
<select id="reg-user">${usersOpts}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Registration Key</label>
|
|
<input type="text" id="reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
|
|
</div>
|
|
<p class="help-text">The registration key is shown when the client tries to connect.</p>
|
|
<button class="btn btn-primary btn-block" onclick="registerNode()">Register</button>
|
|
`);
|
|
}
|
|
|
|
async function registerNode() {
|
|
const user = document.getElementById('reg-user').value;
|
|
let key = document.getElementById('reg-key').value.trim();
|
|
if (!user || !key) { toast('Fill all fields', true); return; }
|
|
key = extractKey(key);
|
|
try {
|
|
const resp = await api('api/admin/register', 'POST', { user, key });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; }
|
|
toast('Node registered!');
|
|
closeModal();
|
|
refreshNodes();
|
|
refreshDashboard();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
function extractKey(input) {
|
|
// Extract nodekey from URL or text
|
|
const match = input.match(/key=([a-f0-9]+)/i);
|
|
if (match) return match[1];
|
|
// Remove nodekey: or mkey: prefix
|
|
return input.replace(/^(nodekey:|mkey:)/, '');
|
|
}
|
|
|
|
// ==================== My Nodes (user) ====================
|
|
|
|
async function refreshMyNodes() {
|
|
try {
|
|
const resp = await api('api/user/nodes');
|
|
const data = await resp.json();
|
|
const nodes = data.nodes || [];
|
|
renderMyNodes(nodes);
|
|
} catch (e) { console.error('my-nodes', e); }
|
|
}
|
|
|
|
function renderMyNodes(nodes) {
|
|
const tbody = document.querySelector('#my-nodes-table tbody');
|
|
if (!nodes.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No nodes registered. Click "+ Register Node" to add one.</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = nodes.map(n => {
|
|
const on = isOnline(n);
|
|
const ip = (n.ipAddresses || [])[0] || '-';
|
|
const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never';
|
|
const name = n.givenName || n.name || '-';
|
|
return `<tr>
|
|
<td><strong>${esc(name)}</strong></td>
|
|
<td><code>${esc(ip)}</code></td>
|
|
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
|
|
<td>${lastSeen}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function showUserRegisterModal() {
|
|
openModal('Register My Node', `
|
|
<div class="form-group">
|
|
<label>Registration Key</label>
|
|
<input type="text" id="user-reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
|
|
</div>
|
|
<p class="help-text">Open Tailscale Custom client → it will show a registration URL. Copy the key from there and paste it here.</p>
|
|
<button class="btn btn-primary btn-block" onclick="userRegisterNode()">Register</button>
|
|
`);
|
|
}
|
|
|
|
async function userRegisterNode() {
|
|
let key = document.getElementById('user-reg-key').value.trim();
|
|
if (!key) { toast('Enter registration key', true); return; }
|
|
key = extractKey(key);
|
|
try {
|
|
const resp = await api('api/user/register', 'POST', { key });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; }
|
|
toast('Node registered!');
|
|
closeModal();
|
|
refreshMyNodes();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Downloads ====================
|
|
|
|
async function refreshDownloads() {
|
|
try {
|
|
const resp = await fetch('download/');
|
|
const data = await resp.json();
|
|
const files = data.files || [];
|
|
const container = document.getElementById('downloads-list');
|
|
|
|
if (!files.length) {
|
|
container.innerHTML = '<div class="empty-state"><p>No downloads available yet.</p></div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = files.map(f => {
|
|
const sizeStr = formatSize(f.size);
|
|
let icon, desc;
|
|
if (f.name.endsWith('.msi')) { icon = '💿'; desc = 'Windows Installer (MSI) — auto-register service'; }
|
|
else if (f.name.endsWith('.zip')) { icon = '📦'; desc = 'Windows Portable (ZIP) — manual service setup'; }
|
|
else if (f.name.match(/linux/)) { icon = '🐧'; desc = 'Linux (amd64)'; }
|
|
else if (f.name.endsWith('.exe')) { icon = '⚙'; desc = 'Windows executable'; }
|
|
else { icon = '📄'; desc = ''; }
|
|
return `<div class="download-card">
|
|
<div class="download-icon">${icon}</div>
|
|
<div class="download-info">
|
|
<div class="download-name">${esc(f.name)}</div>
|
|
<div class="download-meta">${desc ? esc(desc) + ' — ' : ''}${sizeStr}</div>
|
|
</div>
|
|
<a href="download/${encodeURIComponent(f.name)}" class="btn btn-primary btn-sm" download>Download</a>
|
|
</div>`;
|
|
}).join('');
|
|
} catch (e) { console.error('downloads', e); }
|
|
}
|
|
|
|
// ==================== Change Password ====================
|
|
|
|
function showPasswordModal() {
|
|
openModal('Change Password', `
|
|
<div class="form-group">
|
|
<label>Current Password</label>
|
|
<input type="password" id="chg-old-pw">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>New Password</label>
|
|
<input type="password" id="chg-new-pw" placeholder="min 12 characters">
|
|
</div>
|
|
<button class="btn btn-primary btn-block" onclick="changePassword()">Change</button>
|
|
`);
|
|
}
|
|
|
|
async function changePassword() {
|
|
const oldPassword = document.getElementById('chg-old-pw').value;
|
|
const newPassword = document.getElementById('chg-new-pw').value;
|
|
if (!oldPassword || !newPassword) { toast('Fill all fields', true); return; }
|
|
try {
|
|
const resp = await api('api/auth/password', 'POST', { oldPassword, newPassword });
|
|
const data = await resp.json();
|
|
if (!resp.ok) { toast(data.error || 'Failed', true); return; }
|
|
toast('Password changed');
|
|
closeModal();
|
|
} catch (e) { toast('Error: ' + e.message, true); }
|
|
}
|
|
|
|
// ==================== Modal helpers ====================
|
|
|
|
function openModal(title, bodyHtml) {
|
|
document.getElementById('modal-title').textContent = title;
|
|
document.getElementById('modal-body').innerHTML = bodyHtml;
|
|
document.getElementById('modal-overlay').style.display = 'flex';
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal-overlay').style.display = 'none';
|
|
}
|
|
|
|
// ==================== Utility ====================
|
|
|
|
function isOnline(node) {
|
|
if (!node.lastSeen) return false;
|
|
const diff = Date.now() - new Date(node.lastSeen).getTime();
|
|
return node.online === true || diff < 300000;
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
|
return Math.floor(diff / 86400000) + 'd ago';
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
}
|
|
|
|
function esc(str) {
|
|
const d = document.createElement('div');
|
|
d.textContent = str || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function toast(msg, isError = false) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.className = 'toast' + (isError ? ' toast-error' : ' toast-success');
|
|
el.style.display = 'block';
|
|
setTimeout(() => { el.style.display = 'none'; }, 3000);
|
|
}
|
|
|
|
// Enter key for login
|
|
document.getElementById('login-password').addEventListener('keyup', (e) => { if (e.key === 'Enter') doLogin(); });
|
|
document.getElementById('login-username').addEventListener('keyup', (e) => { if (e.key === 'Enter') document.getElementById('login-password').focus(); });
|
|
|
|
// Auto-login from session
|
|
(function init() {
|
|
const token = sessionStorage.getItem('authToken');
|
|
const user = sessionStorage.getItem('authUser');
|
|
if (token && user) {
|
|
authToken = token;
|
|
currentUser = JSON.parse(user);
|
|
// Verify session is still valid
|
|
fetch('api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
|
.then(r => {
|
|
if (r.ok) enterApp();
|
|
else doLogout();
|
|
})
|
|
.catch(() => doLogout());
|
|
}
|
|
})();
|