From 5f18a2f925a10f0a942e4212eaf5437b76b50b0b Mon Sep 17 00:00:00 2001 From: huanld Date: Fri, 10 Apr 2026 17:44:15 +0700 Subject: [PATCH] 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 --- .gitignore | Bin 989 -> 1108 bytes web-admin/.env.example | 6 + web-admin/Dockerfile | 12 ++ web-admin/README.md | 47 +++++ web-admin/docker-compose.yml | 28 +++ web-admin/go.mod | 4 + web-admin/main.go | 288 ++++++++++++++++++++++++++++++ web-admin/static/app.js | 331 +++++++++++++++++++++++++++++++++++ web-admin/static/index.html | 134 ++++++++++++++ web-admin/static/style.css | 139 +++++++++++++++ 10 files changed, 989 insertions(+) create mode 100644 web-admin/.env.example create mode 100644 web-admin/Dockerfile create mode 100644 web-admin/README.md create mode 100644 web-admin/docker-compose.yml create mode 100644 web-admin/go.mod create mode 100644 web-admin/main.go create mode 100644 web-admin/static/app.js create mode 100644 web-admin/static/index.html create mode 100644 web-admin/static/style.css diff --git a/.gitignore b/.gitignore index 4bfabc80f0415643cc317754f7982fc9533e98d0..a3ab021055948b941a94c5ad730dfce9694029e8 100644 GIT binary patch delta 512 zcmZvYu};G<5Qd#*02#R>>d+z33M43r#Kg>igj5Of1jw@xuykOs&fo95|IYV)useJkT{C*IjwcUQjR9J~AhpKeZ3iz}l^xa#M%iXM z7&0msm2K8%DW-j#A7N1d9}stU6wx+F+mqzB&*&&yf|1r0D4FCTVcV}{ z7|U`nOD9nMNk%O4(hDo0%j@?pZ=y1@H4k%CqRsuxSO=L=ZB{l)%gljqbjUD;(c+KL zZqb{dom4J%=4UVx_F|)#1@8eCX?q^$? zP9E`Vt!O+rA9(0?*ou{P3tw{qk_R3Og2!caYpb{@N@e~LaeSQgH67z3lmy<*`I(NV rF+KO;*=h-n(G>wwk}JZABo$G_kb=ykhU|%mQAgT;&m*_-Fha>M&PnclO?zq6^g$K$Tjs zG?tXK_~#mT*gPF4(7c`u;wU3AG~4Tb^uA_j&y%Y?7-lPU+6MvcV{$i&nSEBuC1$)Z zlsWIHLS<$~<&dLW1qJPQG6cw1q`b!XZ<0>VAfNk6@ LA}5}Ct1-Y22#baU diff --git a/web-admin/.env.example b/web-admin/.env.example new file mode 100644 index 000000000..39f382b05 --- /dev/null +++ b/web-admin/.env.example @@ -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 diff --git a/web-admin/Dockerfile b/web-admin/Dockerfile new file mode 100644 index 000000000..e7c517f65 --- /dev/null +++ b/web-admin/Dockerfile @@ -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"] diff --git a/web-admin/README.md b/web-admin/README.md new file mode 100644 index 000000000..259405b3d --- /dev/null +++ b/web-admin/README.md @@ -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 | diff --git a/web-admin/docker-compose.yml b/web-admin/docker-compose.yml new file mode 100644 index 000000000..9cd593ea7 --- /dev/null +++ b/web-admin/docker-compose.yml @@ -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 diff --git a/web-admin/go.mod b/web-admin/go.mod new file mode 100644 index 000000000..5f114cbce --- /dev/null +++ b/web-admin/go.mod @@ -0,0 +1,4 @@ +module tailscale-custom/web-admin + +go 1.22 + diff --git a/web-admin/main.go b/web-admin/main.go new file mode 100644 index 000000000..7bee20a2f --- /dev/null +++ b/web-admin/main.go @@ -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)}) +} diff --git a/web-admin/static/app.js b/web-admin/static/app.js new file mode 100644 index 000000000..490e7bae9 --- /dev/null +++ b/web-admin/static/app.js @@ -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 = 'No nodes registered'; + 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 ` + ${n.id} + ${esc(name)} + ${esc(ips)} + ${esc(userName)} + ${online ? 'Online' : 'Offline'} + ${timeAgo(n.lastSeen)} + ${fmtDate(n.createdAt)} + + + + + `; + }).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 ` + ${esc(name)} + ${esc(ips)} + ${esc(userName)} + ${online ? 'Online' : 'Offline'} + ${timeAgo(n.lastSeen)} + `; + }).join('') || 'No nodes yet'; +} + +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 = 'No users'; + return; + } + tbody.innerHTML = users.map(u => { + const name = u.name || u.Name || '-'; + return ` + ${u.id} + ${esc(name)} + ${fmtDate(u.createdAt)} + + `; + }).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 ``; + }).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 = 'No pre-auth keys'; + 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 ` + ${esc(keyShort)} + ${esc(k._user || k.user || '-')} + ${k.reusable ? '✅' : '—'} + ${k.ephemeral ? '✅' : '—'} + ${k.used ? '✅' : '—'} + ${expired ? 'Expired' : fmtDate(k.expiration)} + ${fmtDate(k.createdAt)} + `; + }).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); diff --git a/web-admin/static/index.html b/web-admin/static/index.html new file mode 100644 index 000000000..301f43da6 --- /dev/null +++ b/web-admin/static/index.html @@ -0,0 +1,134 @@ + + + + + +Headscale Admin + + + + + + + + + + + + diff --git a/web-admin/static/style.css b/web-admin/static/style.css new file mode 100644 index 000000000..80fea673e --- /dev/null +++ b/web-admin/static/style.css @@ -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; }