feat: Add Headscale Web Admin panel
- Go backend proxying Headscale REST API - Dashboard: total nodes, online/offline, users count - Nodes management: list, delete, expire - Users management: create, delete - Pre-auth keys: create reusable/ephemeral keys - Password-protected web UI - Docker + docker-compose deployment - Auto-refresh every 30s - Dark theme UI
This commit is contained in:
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
# Headscale API key (required)
|
||||
# Generate: docker exec headscale headscale apikeys create --expiration 365d
|
||||
HEADSCALE_API_KEY=your_api_key_here
|
||||
|
||||
# Admin password for web UI
|
||||
ADMIN_PASSWORD=admin123
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /headscale-admin .
|
||||
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /headscale-admin /usr/local/bin/headscale-admin
|
||||
EXPOSE 9080
|
||||
ENTRYPOINT ["headscale-admin"]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Headscale Web Admin
|
||||
|
||||
Web-based admin panel for managing Headscale VPN nodes, users, and pre-auth keys.
|
||||
|
||||
## Features
|
||||
- **Dashboard**: Total nodes, online/offline count
|
||||
- **Nodes**: List, delete, expire nodes
|
||||
- **Users**: Create, delete users
|
||||
- **Pre-auth Keys**: Create reusable/ephemeral keys for auto-registration
|
||||
- **Auto-refresh**: Node status updates every 30s
|
||||
- **Simple auth**: Password-protected admin panel
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Copy env file
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Edit .env with your Headscale API key
|
||||
# Generate key: docker exec headscale headscale apikeys create --expiration 365d
|
||||
|
||||
# 3. Start
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Admin panel: `http://your-server:9080`
|
||||
|
||||
## Standalone (without Headscale in same compose)
|
||||
|
||||
```bash
|
||||
docker build -t headscale-admin .
|
||||
docker run -d --name headscale-admin \
|
||||
-p 9080:9080 \
|
||||
-e HEADSCALE_URL=http://your-headscale:8080 \
|
||||
-e HEADSCALE_API_KEY=your_key \
|
||||
-e ADMIN_PASSWORD=your_password \
|
||||
headscale-admin
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `HEADSCALE_URL` | Yes | `http://localhost:8080` | Headscale API URL |
|
||||
| `HEADSCALE_API_KEY` | Yes | - | Headscale API key |
|
||||
| `ADMIN_PASSWORD` | No | - | Web UI password (empty = no auth) |
|
||||
| `LISTEN_ADDR` | No | `:9080` | Listen address |
|
||||
@@ -0,0 +1,28 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
headscale:
|
||||
image: headscale/headscale:latest
|
||||
container_name: headscale
|
||||
restart: always
|
||||
volumes:
|
||||
- ./headscale/config:/etc/headscale
|
||||
- ./headscale/data:/var/lib/headscale
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "3478:3478/udp"
|
||||
command: serve
|
||||
|
||||
headscale-admin:
|
||||
build: .
|
||||
container_name: headscale-admin
|
||||
restart: always
|
||||
ports:
|
||||
- "9080:9080"
|
||||
environment:
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- LISTEN_ADDR=:9080
|
||||
depends_on:
|
||||
- headscale
|
||||
@@ -0,0 +1,4 @@
|
||||
module tailscale-custom/web-admin
|
||||
|
||||
go 1.22
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
var (
|
||||
headscaleURL string
|
||||
apiKey string
|
||||
listenAddr string
|
||||
adminPass string
|
||||
)
|
||||
|
||||
func main() {
|
||||
headscaleURL = getEnv("HEADSCALE_URL", "http://localhost:8080")
|
||||
apiKey = getEnv("HEADSCALE_API_KEY", "")
|
||||
listenAddr = getEnv("LISTEN_ADDR", ":9080")
|
||||
adminPass = getEnv("ADMIN_PASSWORD", "")
|
||||
|
||||
if apiKey == "" {
|
||||
log.Fatal("HEADSCALE_API_KEY is required")
|
||||
}
|
||||
|
||||
headscaleURL = strings.TrimRight(headscaleURL, "/")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes - proxy to headscale
|
||||
mux.HandleFunc("/api/nodes", authMiddleware(handleNodes))
|
||||
mux.HandleFunc("/api/nodes/", authMiddleware(handleNodeByID))
|
||||
mux.HandleFunc("/api/users", authMiddleware(handleUsers))
|
||||
mux.HandleFunc("/api/users/", authMiddleware(handleUserByName))
|
||||
mux.HandleFunc("/api/preauthkeys", authMiddleware(handlePreauthKeys))
|
||||
mux.HandleFunc("/api/routes", authMiddleware(handleRoutes))
|
||||
mux.HandleFunc("/api/routes/", authMiddleware(handleRouteByID))
|
||||
mux.HandleFunc("/api/auth", handleAuth)
|
||||
|
||||
// Static files
|
||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
log.Printf("Headscale Web Admin starting on %s", listenAddr)
|
||||
log.Printf("Headscale API: %s", headscaleURL)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, mux))
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Simple auth check
|
||||
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if adminPass != "" {
|
||||
token := r.Header.Get("X-Admin-Token")
|
||||
if token != adminPass {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
if adminPass == "" || req.Password == adminPass {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
} else {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "wrong password"})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
func handleNodes(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, "/api/v1/node")
|
||||
case http.MethodPost:
|
||||
// Register node
|
||||
proxyPost(w, r, "/api/v1/node/register")
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNodeByID(w http.ResponseWriter, r *http.Request) {
|
||||
// /api/nodes/{id} or /api/nodes/{id}/action
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/nodes/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
nodeID := parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
action := parts[1]
|
||||
switch action {
|
||||
case "expire":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/expire", nodeID))
|
||||
case "rename":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/rename", nodeID))
|
||||
case "routes":
|
||||
proxyGet(w, fmt.Sprintf("/api/v1/node/%s/routes", nodeID))
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
||||
case http.MethodDelete:
|
||||
proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
proxyGet(w, "/api/v1/user")
|
||||
case http.MethodPost:
|
||||
proxyPost(w, r, "/api/v1/user")
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
if r.Method == http.MethodDelete {
|
||||
proxyDelete(w, fmt.Sprintf("/api/v1/user/%s", name))
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pre-auth Keys ---
|
||||
|
||||
func handlePreauthKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
user := r.URL.Query().Get("user")
|
||||
url := "/api/v1/preauthkey"
|
||||
if user != "" {
|
||||
url += "?user=" + user
|
||||
}
|
||||
proxyGet(w, url)
|
||||
case http.MethodPost:
|
||||
proxyPost(w, r, "/api/v1/preauthkey")
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
func handleRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
proxyGet(w, "/api/v1/routes")
|
||||
} else {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRouteByID(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/routes/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
routeID := parts[0]
|
||||
|
||||
if len(parts) == 2 {
|
||||
action := parts[1]
|
||||
switch action {
|
||||
case "enable":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/enable", routeID))
|
||||
case "disable":
|
||||
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/disable", routeID))
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// --- Proxy helpers ---
|
||||
|
||||
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
func proxyGet(w http.ResponseWriter, path string) {
|
||||
url := headscaleURL + path
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func proxyPost(w http.ResponseWriter, r *http.Request, path string) {
|
||||
url := headscaleURL + path
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func proxyDelete(w http.ResponseWriter, path string) {
|
||||
url := headscaleURL + path
|
||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if err != nil {
|
||||
httpError(w, "failed to create request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
httpError(w, "headscale API error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, msg string, err error) {
|
||||
log.Printf("ERROR: %s: %v", msg, err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)})
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Headscale Web Admin - Frontend
|
||||
|
||||
let adminToken = '';
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
function doLogin() {
|
||||
const pw = document.getElementById('login-password').value;
|
||||
fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
adminToken = pw;
|
||||
sessionStorage.setItem('token', pw);
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
refreshAll();
|
||||
} else {
|
||||
document.getElementById('login-error').textContent = 'Wrong password';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('login-error').textContent = 'Connection error';
|
||||
});
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
adminToken = '';
|
||||
sessionStorage.removeItem('token');
|
||||
document.getElementById('app').style.display = 'none';
|
||||
document.getElementById('login-screen').style.display = 'flex';
|
||||
document.getElementById('login-password').value = '';
|
||||
}
|
||||
|
||||
// Auto-login from session
|
||||
(function() {
|
||||
const saved = sessionStorage.getItem('token');
|
||||
if (saved) {
|
||||
adminToken = saved;
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').style.display = 'block';
|
||||
refreshAll();
|
||||
}
|
||||
})();
|
||||
|
||||
// --- API helpers ---
|
||||
|
||||
function api(path, opts = {}) {
|
||||
const headers = { 'X-Admin-Token': adminToken, ...opts.headers };
|
||||
if (opts.body && typeof opts.body === 'object') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
return fetch(path, { ...opts, headers }).then(r => {
|
||||
if (r.status === 401) { doLogout(); throw new Error('unauthorized'); }
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show ' + type;
|
||||
setTimeout(() => el.className = 'toast', 3000);
|
||||
}
|
||||
|
||||
// --- Tabs ---
|
||||
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
let nodesData = [];
|
||||
let usersData = [];
|
||||
|
||||
function refreshAll() {
|
||||
refreshNodes();
|
||||
refreshUsers();
|
||||
refreshKeys();
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
|
||||
function refreshNodes() {
|
||||
api('/api/nodes').then(data => {
|
||||
const nodes = data.nodes || [];
|
||||
nodesData = nodes;
|
||||
renderNodes(nodes);
|
||||
renderDashboard(nodes);
|
||||
}).catch(e => toast('Failed to load nodes: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function renderNodes(nodes) {
|
||||
const tbody = document.getElementById('nodes-table');
|
||||
if (!nodes.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#64748b;padding:40px">No nodes registered</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = nodes.map(n => {
|
||||
const online = isOnline(n);
|
||||
const ips = (n.ipAddresses || []).join(', ');
|
||||
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
|
||||
const name = n.givenName || n.name || '-';
|
||||
return `<tr>
|
||||
<td>${n.id}</td>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td><code style="font-size:12px">${esc(ips)}</code></td>
|
||||
<td>${esc(userName)}</td>
|
||||
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
|
||||
<td>${timeAgo(n.lastSeen)}</td>
|
||||
<td>${fmtDate(n.createdAt)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteNode('${n.id}','${esc(name)}')">Delete</button>
|
||||
<button class="btn btn-sm" onclick="expireNode('${n.id}')">Expire</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderDashboard(nodes) {
|
||||
const online = nodes.filter(isOnline).length;
|
||||
document.getElementById('stat-total').textContent = nodes.length;
|
||||
document.getElementById('stat-online').textContent = online;
|
||||
document.getElementById('stat-offline').textContent = nodes.length - online;
|
||||
|
||||
// Recent 5 nodes
|
||||
const recent = [...nodes].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 5);
|
||||
const tbody = document.getElementById('dashboard-nodes');
|
||||
tbody.innerHTML = recent.map(n => {
|
||||
const online = isOnline(n);
|
||||
const ips = (n.ipAddresses || []).join(', ');
|
||||
const userName = n.user ? (n.user.name || n.user.Name || '-') : '-';
|
||||
const name = n.givenName || n.name || '-';
|
||||
return `<tr>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td><code style="font-size:12px">${esc(ips)}</code></td>
|
||||
<td>${esc(userName)}</td>
|
||||
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
|
||||
<td>${timeAgo(n.lastSeen)}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="5" style="text-align:center;color:#64748b">No nodes yet</td></tr>';
|
||||
}
|
||||
|
||||
function deleteNode(id, name) {
|
||||
if (!confirm(`Delete node "${name}" (ID: ${id})?`)) return;
|
||||
api('/api/nodes/' + id, { method: 'DELETE' })
|
||||
.then(() => { toast('Node deleted'); refreshNodes(); })
|
||||
.catch(e => toast('Delete failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function expireNode(id) {
|
||||
api('/api/nodes/' + id + '/expire', { method: 'POST' })
|
||||
.then(() => { toast('Node expired'); refreshNodes(); })
|
||||
.catch(e => toast('Expire failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
function refreshUsers() {
|
||||
api('/api/users').then(data => {
|
||||
const users = data.users || [];
|
||||
usersData = users;
|
||||
renderUsers(users);
|
||||
document.getElementById('stat-users').textContent = users.length;
|
||||
updateKeyUserSelect(users);
|
||||
}).catch(e => toast('Failed to load users: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
const tbody = document.getElementById('users-table');
|
||||
if (!users.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#64748b;padding:40px">No users</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = users.map(u => {
|
||||
const name = u.name || u.Name || '-';
|
||||
return `<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><strong>${esc(name)}</strong></td>
|
||||
<td>${fmtDate(u.createdAt)}</td>
|
||||
<td><button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(name)}')">Delete</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createUser() {
|
||||
const input = document.getElementById('new-user-name');
|
||||
const name = input.value.trim();
|
||||
if (!name) { toast('Enter a username', 'error'); return; }
|
||||
|
||||
api('/api/users', { method: 'POST', body: { name: name } })
|
||||
.then(() => { toast('User created: ' + name); input.value = ''; refreshUsers(); })
|
||||
.catch(e => toast('Create failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function deleteUser(name) {
|
||||
if (!confirm(`Delete user "${name}"? All nodes of this user will also be removed.`)) return;
|
||||
api('/api/users/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
.then(() => { toast('User deleted'); refreshUsers(); refreshNodes(); })
|
||||
.catch(e => toast('Delete failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
// --- Pre-auth Keys ---
|
||||
|
||||
function updateKeyUserSelect(users) {
|
||||
const sel = document.getElementById('key-user-select');
|
||||
sel.innerHTML = users.map(u => {
|
||||
const name = u.name || u.Name;
|
||||
return `<option value="${esc(name)}">${esc(name)}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshKeys() {
|
||||
// Need to fetch keys per user
|
||||
if (!usersData.length) {
|
||||
api('/api/users').then(data => {
|
||||
usersData = data.users || [];
|
||||
fetchAllKeys();
|
||||
});
|
||||
} else {
|
||||
fetchAllKeys();
|
||||
}
|
||||
}
|
||||
|
||||
function fetchAllKeys() {
|
||||
const promises = usersData.map(u => {
|
||||
const name = u.name || u.Name;
|
||||
return api('/api/preauthkeys?user=' + encodeURIComponent(name))
|
||||
.then(data => (data.preAuthKeys || []).map(k => ({ ...k, _user: name })))
|
||||
.catch(() => []);
|
||||
});
|
||||
|
||||
Promise.all(promises).then(results => {
|
||||
const allKeys = results.flat().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
renderKeys(allKeys);
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const tbody = document.getElementById('keys-table');
|
||||
if (!keys.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#64748b;padding:40px">No pre-auth keys</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = keys.map(k => {
|
||||
const expired = k.expiration && new Date(k.expiration) < new Date();
|
||||
const keyShort = k.key ? k.key.substring(0, 16) + '...' : '-';
|
||||
return `<tr>
|
||||
<td><span class="key-text" title="Click to copy full key" onclick="copyKey('${esc(k.key)}')">${esc(keyShort)}</span></td>
|
||||
<td>${esc(k._user || k.user || '-')}</td>
|
||||
<td>${k.reusable ? '✅' : '—'}</td>
|
||||
<td>${k.ephemeral ? '✅' : '—'}</td>
|
||||
<td>${k.used ? '✅' : '—'}</td>
|
||||
<td>${expired ? '<span class="badge badge-expired">Expired</span>' : fmtDate(k.expiration)}</td>
|
||||
<td>${fmtDate(k.createdAt)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
const user = document.getElementById('key-user-select').value;
|
||||
if (!user) { toast('Select a user first', 'error'); return; }
|
||||
|
||||
const reusable = document.getElementById('key-reusable').checked;
|
||||
const ephemeral = document.getElementById('key-ephemeral').checked;
|
||||
const hours = parseInt(document.getElementById('key-expiry').value) || 24;
|
||||
|
||||
const expiration = new Date(Date.now() + hours * 3600000).toISOString();
|
||||
|
||||
api('/api/preauthkeys', {
|
||||
method: 'POST',
|
||||
body: { user: user, reusable, ephemeral, expiration }
|
||||
}).then(data => {
|
||||
const key = data.preAuthKey ? data.preAuthKey.key : 'created';
|
||||
toast('Key created: ' + key.substring(0, 20) + '...');
|
||||
refreshKeys();
|
||||
}).catch(e => toast('Create key failed: ' + e.message, 'error'));
|
||||
}
|
||||
|
||||
function copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(
|
||||
() => toast('Key copied to clipboard'),
|
||||
() => toast('Copy failed', 'error')
|
||||
);
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function isOnline(node) {
|
||||
if (!node.lastSeen) return false;
|
||||
const diff = Date.now() - new Date(node.lastSeen).getTime();
|
||||
return node.online === true || diff < 5 * 60 * 1000; // 5 min
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return mins + 'm ago';
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
const days = Math.floor(hours / 24);
|
||||
return days + 'd ago';
|
||||
}
|
||||
|
||||
function fmtDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('vi-VN') + ' ' + d.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Auto-refresh every 30s
|
||||
setInterval(() => {
|
||||
if (adminToken) refreshNodes();
|
||||
}, 30000);
|
||||
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Headscale Admin</title>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login screen -->
|
||||
<div id="login-screen" class="login-screen">
|
||||
<div class="login-box">
|
||||
<h1>🔒 Headscale Admin</h1>
|
||||
<input type="password" id="login-password" placeholder="Admin password" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button onclick="doLogin()">Login</button>
|
||||
<p id="login-error" class="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main app -->
|
||||
<div id="app" style="display:none">
|
||||
<nav>
|
||||
<div class="nav-brand">⚡ Headscale Admin</div>
|
||||
<div class="nav-tabs">
|
||||
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
|
||||
<button class="tab" data-tab="nodes" onclick="switchTab('nodes')">Nodes</button>
|
||||
<button class="tab" data-tab="users" onclick="switchTab('users')">Users</button>
|
||||
<button class="tab" data-tab="keys" onclick="switchTab('keys')">Auth Keys</button>
|
||||
</div>
|
||||
<button class="btn-logout" onclick="doLogout()">Logout</button>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="stat-total">-</div>
|
||||
<div class="stat-label">Total Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card stat-online">
|
||||
<div class="stat-number" id="stat-online">-</div>
|
||||
<div class="stat-label">Online</div>
|
||||
</div>
|
||||
<div class="stat-card stat-offline">
|
||||
<div class="stat-number" id="stat-offline">-</div>
|
||||
<div class="stat-label">Offline</div>
|
||||
</div>
|
||||
<div class="stat-card stat-users">
|
||||
<div class="stat-number" id="stat-users">-</div>
|
||||
<div class="stat-label">Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Recent Nodes</h3>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th></tr></thead>
|
||||
<tbody id="dashboard-nodes"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes -->
|
||||
<div id="tab-nodes" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Nodes</h2>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="refreshNodes()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>User</th>
|
||||
<th>Status</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="nodes-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div id="tab-users" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Users</h2>
|
||||
<div>
|
||||
<input type="text" id="new-user-name" placeholder="Username">
|
||||
<button class="btn btn-primary" onclick="createUser()">+ Create User</button>
|
||||
<button class="btn" onclick="refreshUsers()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
|
||||
<tbody id="users-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-auth Keys -->
|
||||
<div id="tab-keys" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<h2>Pre-Auth Keys</h2>
|
||||
<div>
|
||||
<select id="key-user-select"></select>
|
||||
<label><input type="checkbox" id="key-reusable" checked> Reusable</label>
|
||||
<label><input type="checkbox" id="key-ephemeral"> Ephemeral</label>
|
||||
<input type="number" id="key-expiry" value="24" min="1" style="width:60px"> hours
|
||||
<button class="btn btn-primary" onclick="createKey()">+ Create Key</button>
|
||||
<button class="btn" onclick="refreshKeys()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th><th>Created</th></tr></thead>
|
||||
<tbody id="keys-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast"></div>
|
||||
</div>
|
||||
|
||||
<script src="static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-screen {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; background: #0f172a;
|
||||
}
|
||||
.login-box {
|
||||
background: #1e293b; padding: 40px; border-radius: 12px;
|
||||
text-align: center; min-width: 320px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,.5);
|
||||
}
|
||||
.login-box h1 { margin-bottom: 24px; font-size: 1.5rem; }
|
||||
.login-box input {
|
||||
width: 100%; padding: 10px 14px; margin-bottom: 12px;
|
||||
background: #0f172a; border: 1px solid #334155; border-radius: 6px;
|
||||
color: #e2e8f0; font-size: 14px;
|
||||
}
|
||||
.login-box button {
|
||||
width: 100%; padding: 10px; background: #3b82f6; color: #fff;
|
||||
border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.login-box button:hover { background: #2563eb; }
|
||||
.error { color: #ef4444; margin-top: 8px; font-size: 13px; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 24px; height: 56px;
|
||||
background: #1e293b; border-bottom: 1px solid #334155;
|
||||
}
|
||||
.nav-brand { font-weight: 700; font-size: 1.1rem; margin-right: 24px; }
|
||||
.nav-tabs { display: flex; gap: 4px; flex: 1; }
|
||||
.tab {
|
||||
padding: 8px 16px; background: transparent; color: #94a3b8;
|
||||
border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.tab:hover { background: #334155; color: #e2e8f0; }
|
||||
.tab.active { background: #3b82f6; color: #fff; }
|
||||
.btn-logout {
|
||||
padding: 6px 12px; background: transparent; color: #94a3b8;
|
||||
border: 1px solid #475569; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.btn-logout:hover { background: #ef4444; color: #fff; border-color: #ef4444; }
|
||||
|
||||
/* Tab content */
|
||||
.tab-content { display: none; padding: 24px; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px; margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #1e293b; padding: 24px; border-radius: 10px;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.stat-card.stat-online { border-left-color: #22c55e; }
|
||||
.stat-card.stat-offline { border-left-color: #ef4444; }
|
||||
.stat-card.stat-users { border-left-color: #a855f7; }
|
||||
.stat-number { font-size: 2rem; font-weight: 700; }
|
||||
.stat-label { color: #94a3b8; font-size: 13px; margin-top: 4px; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #1e293b; border-radius: 10px; padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td {
|
||||
text-align: left; padding: 10px 12px; font-size: 13px;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:hover { background: #263248; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
.toolbar h2 { font-size: 1.2rem; }
|
||||
.toolbar div { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar input, .toolbar select {
|
||||
padding: 6px 10px; background: #0f172a; border: 1px solid #334155;
|
||||
border-radius: 6px; color: #e2e8f0; font-size: 13px;
|
||||
}
|
||||
.toolbar label { font-size: 13px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 6px 14px; border: 1px solid #475569; background: transparent;
|
||||
color: #e2e8f0; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.btn:hover { background: #334155; }
|
||||
.btn-primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-danger { background: #ef4444; border-color: #ef4444; color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* Status badge */
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 10px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.badge-online { background: #166534; color: #4ade80; }
|
||||
.badge-offline { background: #7f1d1d; color: #fca5a5; }
|
||||
.badge-expired { background: #78350f; color: #fcd34d; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
|
||||
padding: 12px 20px; font-size: 13px; opacity: 0;
|
||||
transition: opacity .3s; pointer-events: none;
|
||||
z-index: 1000; max-width: 400px;
|
||||
}
|
||||
.toast.show { opacity: 1; pointer-events: auto; }
|
||||
.toast.error { border-color: #ef4444; }
|
||||
.toast.success { border-color: #22c55e; }
|
||||
|
||||
/* Key display */
|
||||
.key-text {
|
||||
font-family: monospace; font-size: 11px; background: #0f172a;
|
||||
padding: 2px 6px; border-radius: 4px; word-break: break-all;
|
||||
cursor: pointer;
|
||||
}
|
||||
.key-text:hover { background: #334155; }
|
||||
Reference in New Issue
Block a user