diff --git a/.gitignore b/.gitignore index 4bfabc80f..a3ab02105 100644 Binary files a/.gitignore and b/.gitignore differ 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 = '
${esc(ips)}${esc(ips)}