5f18a2f925
- 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
289 lines
7.2 KiB
Go
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)})
|
|
}
|