Files
tailscale-custom/web-admin/main.go
T
huanld 5f18a2f925 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
2026-04-10 17:44:15 +07:00

289 lines
7.2 KiB
Go

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)})
}