From 01cbe66ad452bf3632dab5a6075c1e9500b574d8 Mon Sep 17 00:00:00 2001 From: huanld Date: Sat, 11 Apr 2026 11:39:12 +0700 Subject: [PATCH] feat: admin panel with user/account management, foreign keys, reset password, dark theme, Linux client support, DERP relay integration --- cmd/tailscale-tray/main.go | 123 +++-- web-admin/Dockerfile | 1 + web-admin/docker-compose.yml | 6 + web-admin/main.go | 789 +++++++++++++++++++++++++++----- web-admin/static/app.js | 860 +++++++++++++++++++++++++---------- web-admin/static/index.html | 248 +++++----- web-admin/static/style.css | 626 ++++++++++++++++++++----- 7 files changed, 2053 insertions(+), 600 deletions(-) diff --git a/cmd/tailscale-tray/main.go b/cmd/tailscale-tray/main.go index 5221c0481..f46bab356 100644 --- a/cmd/tailscale-tray/main.go +++ b/cmd/tailscale-tray/main.go @@ -14,6 +14,7 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "unsafe" @@ -33,6 +34,7 @@ type app struct { curProfile ipn.LoginProfile allProfiles []ipn.LoginProfile prevState string // track state transitions + inAction int32 // atomic flag to prevent re-entrant actions bgCtx context.Context bgCancel context.CancelFunc @@ -189,22 +191,12 @@ func (a *app) rebuild() { login := systray.AddMenuItem("Login", "Login to server") onClick(ctx, login, func() { log.Println("action: Login") - opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second) - defer opCancel() - if err := a.lc.StartLoginInteractive(opCtx); err != nil { - log.Printf("Login error: %v", err) - tailscaleExe := findTailscaleExe() - serverURL := a.curProfile.ControlURL - if serverURL == "" { - serverURL = "https://vpn.softs.business" - } - exec.Command(tailscaleExe, "login", "--login-server", serverURL).Start() - } + a.doLogin() }) connect := systray.AddMenuItem("Connect", "Connect to VPN") onClick(ctx, connect, func() { log.Println("action: Connect") - a.doEditPrefs(true) + a.doLogin() }) } else { // Stopped or unknown @@ -414,7 +406,53 @@ func (a *app) doEditPrefs(wantRunning bool) { } } +func (a *app) doLogin() { + if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) { + log.Println("doLogin: skipped (action in progress)") + return + } + defer atomic.StoreInt32(&a.inAction, 0) + + serverURL := a.curProfile.ControlURL + if serverURL == "" { + serverURL = "https://vpn.softs.business" + } + log.Printf("doLogin: server=%s", serverURL) + + opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second) + defer opCancel() + + // Use Start() with UpdatePrefs like official CLI does + err := a.lc.Start(opCtx, ipn.Options{ + UpdatePrefs: &ipn.Prefs{ + ControlURL: serverURL, + WantRunning: true, + }, + }) + if err != nil { + log.Printf("doLogin: Start error: %v", err) + } else { + log.Println("doLogin: Start OK") + } + + // Then trigger interactive login to get BrowseToURL + if err := a.lc.StartLoginInteractive(opCtx); err != nil { + log.Printf("doLogin: StartLoginInteractive error: %v", err) + } else { + log.Println("doLogin: StartLoginInteractive OK") + } + + // Poll for AuthURL and open browser + a.openAuthURL() +} + func (a *app) addServer() { + if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) { + log.Println("addServer: skipped (action in progress)") + return + } + defer atomic.StoreInt32(&a.inAction, 0) + serverURL := inputDialog("Add Server", "Enter the control server URL:") if serverURL == "" { log.Println("addServer: cancelled") @@ -428,33 +466,58 @@ func (a *app) addServer() { opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second) defer opCancel() + // Create new empty profile for this server if err := a.lc.SwitchToEmptyProfile(opCtx); err != nil { log.Printf("addServer: SwitchToEmptyProfile error: %v", err) - showError("Failed to create profile:\n" + err.Error()) - return } - _, err := a.lc.EditPrefs(opCtx, &ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ + // Use Start() with UpdatePrefs like official CLI does + err := a.lc.Start(opCtx, ipn.Options{ + UpdatePrefs: &ipn.Prefs{ ControlURL: serverURL, WantRunning: true, }, - ControlURLSet: true, - WantRunningSet: true, }) if err != nil { - log.Printf("addServer: EditPrefs error: %v", err) - showError("Failed to set server:\n" + err.Error()) - return + log.Printf("addServer: Start error: %v", err) + } else { + log.Println("addServer: Start OK") } + // Then trigger interactive login to get BrowseToURL if err := a.lc.StartLoginInteractive(opCtx); err != nil { log.Printf("addServer: StartLoginInteractive error: %v", err) - tailscaleExe := findTailscaleExe() - exec.Command(tailscaleExe, "login", "--login-server", serverURL).Start() + } else { + log.Println("addServer: StartLoginInteractive OK") } + + // Poll for AuthURL and open browser + a.openAuthURL() log.Println("addServer: done") - a.triggerRebuild() +} + +// openAuthURL polls Status.AuthURL and opens the browser when available. +func (a *app) openAuthURL() { + for i := 0; i < 20; i++ { // poll up to ~5 seconds + ctx, cancel := context.WithTimeout(a.bgCtx, 3*time.Second) + st, err := a.lc.Status(ctx) + cancel() + if err != nil { + log.Printf("openAuthURL: Status error: %v", err) + return + } + if st.AuthURL != "" { + log.Printf("openAuthURL: opening %s", st.AuthURL) + exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start() + return + } + if st.BackendState == "Running" { + log.Println("openAuthURL: already running, no auth needed") + return + } + time.Sleep(250 * time.Millisecond) + } + log.Println("openAuthURL: timed out waiting for AuthURL") } // ── IPN Bus Watcher ───────────────────────────────────── @@ -510,6 +573,7 @@ func (a *app) watchIPNBusInner() error { a.triggerRebuild() } if url := n.BrowseToURL; url != nil { + log.Printf("BrowseToURL: %s", *url) exec.Command("rundll32", "url.dll,FileProtocolHandler", *url).Start() } } @@ -538,17 +602,6 @@ func profileTitle(p ipn.LoginProfile) string { return name } -func findTailscaleExe() string { - exe, err := os.Executable() - if err == nil { - candidate := filepath.Join(filepath.Dir(exe), "tailscale.exe") - if _, err := os.Stat(candidate); err == nil { - return candidate - } - } - return "tailscale.exe" -} - // ── Clipboard ─────────────────────────────────────────── func copyToClipboard(text string) { diff --git a/web-admin/Dockerfile b/web-admin/Dockerfile index e7c517f65..7267b5da6 100644 --- a/web-admin/Dockerfile +++ b/web-admin/Dockerfile @@ -7,6 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /headscale-admin . FROM alpine:3.19 RUN apk add --no-cache ca-certificates +RUN mkdir -p /data/downloads COPY --from=builder /headscale-admin /usr/local/bin/headscale-admin EXPOSE 9080 ENTRYPOINT ["headscale-admin"] diff --git a/web-admin/docker-compose.yml b/web-admin/docker-compose.yml index 9cd593ea7..533640ca2 100644 --- a/web-admin/docker-compose.yml +++ b/web-admin/docker-compose.yml @@ -24,5 +24,11 @@ services: - HEADSCALE_API_KEY=${HEADSCALE_API_KEY} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - LISTEN_ADDR=:9080 + - DATA_DIR=/data + volumes: + - admin-data:/data depends_on: - headscale + +volumes: + admin-data: diff --git a/web-admin/main.go b/web-admin/main.go index 8fc87859e..617774edc 100644 --- a/web-admin/main.go +++ b/web-admin/main.go @@ -1,8 +1,11 @@ -package main +package main import ( "bytes" + "crypto/rand" + "crypto/sha256" "embed" + "encoding/hex" "encoding/json" "fmt" "io" @@ -10,7 +13,9 @@ import ( "log" "net/http" "os" + "path/filepath" "strings" + "sync" "time" ) @@ -21,42 +26,184 @@ var ( headscaleURL string apiKey string listenAddr string - adminPass string + dataDir string ) +type AppUser struct { + Username string `json:"username"` + PassHash string `json:"passHash"` + Salt string `json:"salt"` + Role string `json:"role"` + CreatedAt string `json:"createdAt"` +} + +type UserDB struct { + mu sync.RWMutex + Users []AppUser `json:"users"` + path string +} + +func newUserDB(path string) *UserDB { + db := &UserDB{path: path} + data, err := os.ReadFile(path) + if err == nil { + json.Unmarshal(data, &db.Users) + } + return db +} + +func (db *UserDB) Find(username string) *AppUser { + db.mu.RLock() + defer db.mu.RUnlock() + for i := range db.Users { + if strings.EqualFold(db.Users[i].Username, username) { + return &db.Users[i] + } + } + return nil +} + +func (db *UserDB) Add(u AppUser) error { + db.mu.Lock() + defer db.mu.Unlock() + for _, ex := range db.Users { + if strings.EqualFold(ex.Username, u.Username) { + return fmt.Errorf("user already exists") + } + } + db.Users = append(db.Users, u) + data, _ := json.MarshalIndent(db.Users, "", " ") + return os.WriteFile(db.path, data, 0600) +} + +func (db *UserDB) Delete(username string) error { + db.mu.Lock() + defer db.mu.Unlock() + for i, u := range db.Users { + if strings.EqualFold(u.Username, username) { + db.Users = append(db.Users[:i], db.Users[i+1:]...) + data, _ := json.MarshalIndent(db.Users, "", " ") + return os.WriteFile(db.path, data, 0600) + } + } + return fmt.Errorf("user not found") +} + +func (db *UserDB) ChangePassword(username, newPassHash, newSalt string) error { + db.mu.Lock() + defer db.mu.Unlock() + for i := range db.Users { + if strings.EqualFold(db.Users[i].Username, username) { + db.Users[i].PassHash = newPassHash + db.Users[i].Salt = newSalt + data, _ := json.MarshalIndent(db.Users, "", " ") + return os.WriteFile(db.path, data, 0600) + } + } + return fmt.Errorf("user not found") +} + +func (db *UserDB) List() []AppUser { + db.mu.RLock() + defer db.mu.RUnlock() + out := make([]AppUser, len(db.Users)) + copy(out, db.Users) + return out +} + +func hashPassword(password, salt string) string { + h := sha256.Sum256([]byte(salt + password)) + return hex.EncodeToString(h[:]) +} + +func randomSalt() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func verifyPassword(user *AppUser, password string) bool { + return hashPassword(password, user.Salt) == user.PassHash +} + +type Session struct { + Username string + Role string + Created time.Time +} + +var ( + userDB *UserDB + sessions = struct { + mu sync.RWMutex + data map[string]*Session + }{data: make(map[string]*Session)} +) + +func createSession(username, role string) string { + b := make([]byte, 32) + rand.Read(b) + token := hex.EncodeToString(b) + sessions.mu.Lock() + sessions.data[token] = &Session{Username: username, Role: role, Created: time.Now()} + sessions.mu.Unlock() + return token +} + +func getSession(token string) *Session { + sessions.mu.RLock() + defer sessions.mu.RUnlock() + s := sessions.data[token] + if s != nil && time.Since(s.Created) > 24*time.Hour { + return nil + } + return s +} + +func deleteSession(token string) { + sessions.mu.Lock() + delete(sessions.data, token) + sessions.mu.Unlock() +} + func main() { headscaleURL = getEnv("HEADSCALE_URL", "http://localhost:8080") apiKey = getEnv("HEADSCALE_API_KEY", "") listenAddr = getEnv("LISTEN_ADDR", ":9080") - adminPass = getEnv("ADMIN_PASSWORD", "") - + dataDir = getEnv("DATA_DIR", "/data") if apiKey == "" { log.Fatal("HEADSCALE_API_KEY is required") } - headscaleURL = strings.TrimRight(headscaleURL, "/") + os.MkdirAll(filepath.Join(dataDir, "downloads"), 0755) + userDB = newUserDB(filepath.Join(dataDir, "users.json")) + initAdminUser() mux := http.NewServeMux() + mux.HandleFunc("/api/auth/login", handleLogin) + mux.HandleFunc("/api/auth/logout", handleLogout) + mux.HandleFunc("/api/auth/me", handleMe) + mux.HandleFunc("/api/auth/password", requireAuth(handleChangePassword)) + mux.HandleFunc("/api/admin/nodes", requireAdmin(handleAdminNodes)) + mux.HandleFunc("/api/admin/nodes/", requireAdmin(handleAdminNodeByID)) + mux.HandleFunc("/api/admin/register", requireAdmin(handleAdminRegister)) + mux.HandleFunc("/api/admin/users", requireAdmin(handleAdminUsers)) + mux.HandleFunc("/api/admin/users/", requireAdmin(handleAdminUserByID)) + mux.HandleFunc("/api/admin/accounts", requireAdmin(handleAdminAccounts)) + mux.HandleFunc("/api/admin/accounts/", requireAdmin(handleAdminAccountByID)) + mux.HandleFunc("/api/admin/preauthkeys", requireAdmin(handleAdminKeys)) + mux.HandleFunc("/api/admin/routes", requireAdmin(handleAdminRoutes)) + mux.HandleFunc("/api/admin/routes/", requireAdmin(handleAdminRouteByID)) + mux.HandleFunc("/api/user/nodes", requireAuth(handleUserNodes)) + mux.HandleFunc("/api/user/register", requireAuth(handleUserRegister)) + mux.HandleFunc("/download/", handleDownload) - // 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 - strip "static" prefix so files are served at / staticSub, err := fs.Sub(staticFS, "static") if err != nil { log.Fatal(err) } mux.Handle("/", http.FileServer(http.FS(staticSub))) - log.Printf("Headscale Web Admin starting on %s", listenAddr) - log.Printf("Headscale API: %s", headscaleURL) log.Fatal(http.ListenAndServe(listenAddr, mux)) } @@ -67,61 +214,237 @@ func getEnv(key, fallback string) string { return fallback } -// Simple auth check -func authMiddleware(next http.HandlerFunc) http.HandlerFunc { +func initAdminUser() { + if userDB.Find("admin") != nil { + return + } + adminPass := getEnv("ADMIN_PASSWORD", "admin123") + salt := randomSalt() + userDB.Add(AppUser{ + Username: "admin", + PassHash: hashPassword(adminPass, salt), + Salt: salt, + Role: "admin", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + log.Println("Created default admin user") +} + +func requireAuth(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 - } + token := r.Header.Get("Authorization") + token = strings.TrimPrefix(token, "Bearer ") + s := getSession(token) + if s == nil { + jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return } + r.Header.Set("X-Username", s.Username) + r.Header.Set("X-Role", s.Role) next(w, r) } } -func handleAuth(w http.ResponseWriter, r *http.Request) { +func requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Role") != "admin" { + jsonResp(w, http.StatusForbidden, map[string]string{"error": "admin only"}) + return + } + next(w, r) + }) +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req struct { + Username string `json:"username"` 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"}) + req.Username = strings.TrimSpace(req.Username) + user := userDB.Find(req.Username) + if user == nil || !verifyPassword(user, req.Password) { + jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return } + token := createSession(user.Username, user.Role) + jsonResp(w, http.StatusOK, map[string]interface{}{ + "ok": true, "token": token, "username": user.Username, "role": user.Role, + }) } -// --- Nodes --- +func handleLogout(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + deleteSession(token) + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) +} -func handleNodes(w http.ResponseWriter, r *http.Request) { +func handleMe(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + s := getSession(token) + if s == nil { + jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + jsonResp(w, http.StatusOK, map[string]string{"username": s.Username, "role": s.Role}) +} + +func handleChangePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` + } + json.NewDecoder(r.Body).Decode(&req) + username := r.Header.Get("X-Username") + user := userDB.Find(username) + if user == nil || !verifyPassword(user, req.OldPassword) { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "wrong current password"}) + return + } + if len(req.NewPassword) < 4 { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"}) + return + } + salt := randomSalt() + userDB.ChangePassword(username, hashPassword(req.NewPassword, salt), salt) + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func handleAdminAccounts(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - proxyGet(w, "/api/v1/node") + users := userDB.List() + type safeUser struct { + Username string `json:"username"` + Role string `json:"role"` + CreatedAt string `json:"createdAt"` + } + out := make([]safeUser, len(users)) + for i, u := range users { + out[i] = safeUser{Username: u.Username, Role: u.Role, CreatedAt: u.CreatedAt} + } + jsonResp(w, http.StatusOK, map[string]interface{}{"accounts": out}) case http.MethodPost: - // Register node - proxyPost(w, r, "/api/v1/node/register") + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + json.NewDecoder(r.Body).Decode(&req) + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "username and password required"}) + return + } + if len(req.Password) < 4 { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"}) + return + } + if req.Role == "" { + req.Role = "user" + } + if req.Role != "admin" && req.Role != "user" { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "role must be admin or user"}) + return + } + salt := randomSalt() + err := userDB.Add(AppUser{ + Username: req.Username, PassHash: hashPassword(req.Password, salt), + Salt: salt, Role: req.Role, CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + jsonResp(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + if req.Role == "user" { + body, _ := json.Marshal(map[string]string{"name": req.Username}) + hReq, _ := http.NewRequest(http.MethodPost, headscaleURL+"/api/v1/user", bytes.NewReader(body)) + hReq.Header.Set("Authorization", "Bearer "+apiKey) + hReq.Header.Set("Content-Type", "application/json") + httpClient.Do(hReq) + } + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) 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/") +func handleAdminAccountByID(w http.ResponseWriter, r *http.Request) { + username := strings.TrimPrefix(r.URL.Path, "/api/admin/accounts/") + parts := strings.SplitN(username, "/", 2) + username = parts[0] + if len(parts) == 2 && parts[1] == "password" { + 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 len(req.Password) < 4 { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"}) + return + } + salt := randomSalt() + if err := userDB.ChangePassword(username, hashPassword(req.Password, salt), salt); err != nil { + jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) + return + } + if r.Method == http.MethodDelete { + if strings.EqualFold(username, "admin") { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "cannot delete admin"}) + return + } + // Foreign key check: can't delete account if Headscale user has nodes + nodeCount, err := getUserNodeCount(username) + if err != nil { + httpError(w, "failed to check nodes", err) + return + } + if nodeCount > 0 { + jsonResp(w, http.StatusConflict, map[string]string{ + "error": fmt.Sprintf("Cannot delete account '%s': has %d active node(s). Delete all nodes first.", username, nodeCount), + }) + return + } + if err := userDB.Delete(username); err != nil { + jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + // Also delete Headscale user + doHeadscaleRequest(http.MethodDelete, fmt.Sprintf("/api/v1/user/%s", username), nil) + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + +func handleAdminNodes(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + proxyGet(w, "/api/v1/node") + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleAdminNodeByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/admin/nodes/") parts := strings.SplitN(path, "/", 2) nodeID := parts[0] - if len(parts) == 2 { - action := parts[1] - switch action { + switch parts[1] { case "expire": proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/expire", nodeID)) case "rename": @@ -133,50 +456,218 @@ func handleNodeByID(w http.ResponseWriter, r *http.Request) { } 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)) + proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID)) } else { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } -// --- Pre-auth Keys --- +func handleAdminRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + User string `json:"user"` + Key string `json:"key"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.User == "" || req.Key == "" { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "user and key required"}) + return + } + url := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s", headscaleURL, req.User, req.Key) + hReq, _ := http.NewRequest(http.MethodPost, url, nil) + hReq.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := httpClient.Do(hReq) + 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 handlePreauthKeys(w http.ResponseWriter, r *http.Request) { +func getUserNodeCount(username string) (int, error) { + req, _ := http.NewRequest(http.MethodGet, headscaleURL+"/api/v1/node", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := httpClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + var result struct { + Nodes []struct { + User struct { + Name string `json:"name"` + } `json:"user"` + } `json:"nodes"` + } + body, _ := io.ReadAll(resp.Body) + json.Unmarshal(body, &result) + count := 0 + for _, n := range result.Nodes { + if strings.EqualFold(n.User.Name, username) { + count++ + } + } + return count, nil +} + +func doHeadscaleRequest(method, path string, body []byte) (int, []byte, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, headscaleURL+path, bodyReader) + if err != nil { + return 0, nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + return resp.StatusCode, respBody, nil +} + +func handleAdminUsers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + proxyGet(w, "/api/v1/user") + case http.MethodPost: + var req struct { + Name string `json:"name"` + Password string `json:"password"` + } + json.NewDecoder(r.Body).Decode(&req) + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "name required"}) + return + } + if req.Password != "" && len(req.Password) < 4 { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"}) + return + } + // Check if login account already exists + if userDB.Find(req.Name) != nil { + jsonResp(w, http.StatusConflict, map[string]string{"error": "account already exists"}) + return + } + // Create Headscale user + body, _ := json.Marshal(map[string]string{"name": req.Name}) + status, respBody, err := doHeadscaleRequest(http.MethodPost, "/api/v1/user", body) + if err != nil { + httpError(w, "headscale API error", err) + return + } + if status >= 400 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(respBody) + return + } + // Also create login account + if req.Password != "" { + salt := randomSalt() + userDB.Add(AppUser{ + Username: req.Name, + PassHash: hashPassword(req.Password, salt), + Salt: salt, + Role: "user", + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(respBody) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleAdminUserByID(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/api/admin/users/") + parts := strings.SplitN(name, "/", 2) + name = parts[0] + + // Handle /api/admin/users/{name}/password + if len(parts) == 2 && parts[1] == "password" { + 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 len(req.Password) < 4 { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 4)"}) + return + } + user := userDB.Find(name) + if user == nil { + jsonResp(w, http.StatusNotFound, map[string]string{"error": "login account not found for this user"}) + return + } + salt := randomSalt() + if err := userDB.ChangePassword(name, hashPassword(req.Password, salt), salt); err != nil { + jsonResp(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + jsonResp(w, http.StatusOK, map[string]bool{"ok": true}) + return + } + + if r.Method == http.MethodDelete { + // Foreign key check: can't delete user with active nodes + nodeCount, err := getUserNodeCount(name) + if err != nil { + httpError(w, "failed to check nodes", err) + return + } + if nodeCount > 0 { + jsonResp(w, http.StatusConflict, map[string]string{ + "error": fmt.Sprintf("Cannot delete user '%s': has %d active node(s). Delete all nodes first.", name, nodeCount), + }) + return + } + // Delete from Headscale + status, respBody, err := doHeadscaleRequest(http.MethodDelete, fmt.Sprintf("/api/v1/user/%s", name), nil) + if err != nil { + httpError(w, "headscale API error", err) + return + } + if status < 400 { + // Also delete login account if exists + userDB.Delete(name) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(respBody) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + +func handleAdminKeys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: user := r.URL.Query().Get("user") - url := "/api/v1/preauthkey" + u := "/api/v1/preauthkey" if user != "" { - url += "?user=" + user + u += "?user=" + user } - proxyGet(w, url) + proxyGet(w, u) case http.MethodPost: proxyPost(w, r, "/api/v1/preauthkey") default: @@ -184,9 +675,7 @@ func handlePreauthKeys(w http.ResponseWriter, r *http.Request) { } } -// --- Routes --- - -func handleRoutes(w http.ResponseWriter, r *http.Request) { +func handleAdminRoutes(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { proxyGet(w, "/api/v1/routes") } else { @@ -194,14 +683,12 @@ func handleRoutes(w http.ResponseWriter, r *http.Request) { } } -func handleRouteByID(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/routes/") +func handleAdminRouteByID(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/admin/routes/") parts := strings.SplitN(path, "/", 2) routeID := parts[0] - if len(parts) == 2 { - action := parts[1] - switch action { + switch parts[1] { case "enable": proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/enable", routeID)) case "disable": @@ -211,75 +698,155 @@ func handleRouteByID(w http.ResponseWriter, r *http.Request) { } 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) +func handleUserNodes(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + username := r.Header.Get("X-Username") + u := headscaleURL + "/api/v1/node" + req, _ := http.NewRequest(http.MethodGet, u, nil) 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() + var result struct { + Nodes []json.RawMessage `json:"nodes"` + } + body, _ := io.ReadAll(resp.Body) + json.Unmarshal(body, &result) + var userNodes []json.RawMessage + for _, raw := range result.Nodes { + var node struct { + User struct { + Name string `json:"name"` + } `json:"user"` + } + json.Unmarshal(raw, &node) + if strings.EqualFold(node.User.Name, username) { + userNodes = append(userNodes, raw) + } + } + if userNodes == nil { + userNodes = []json.RawMessage{} + } + jsonResp(w, http.StatusOK, map[string]interface{}{"nodes": userNodes}) +} +func handleUserRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username := r.Header.Get("X-Username") + var req struct { + Key string `json:"key"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.Key == "" { + jsonResp(w, http.StatusBadRequest, map[string]string{"error": "key required"}) + return + } + u := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s", headscaleURL, username, req.Key) + hReq, _ := http.NewRequest(http.MethodPost, u, nil) + hReq.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := httpClient.Do(hReq) + 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 handleDownload(w http.ResponseWriter, r *http.Request) { + filename := strings.TrimPrefix(r.URL.Path, "/download/") + if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { + dir := filepath.Join(dataDir, "downloads") + entries, _ := os.ReadDir(dir) + var files []map[string]interface{} + for _, e := range entries { + if !e.IsDir() { + info, _ := e.Info() + files = append(files, map[string]interface{}{"name": e.Name(), "size": info.Size()}) + } + } + if files == nil { + files = []map[string]interface{}{} + } + jsonResp(w, http.StatusOK, map[string]interface{}{"files": files}) + return + } + path := filepath.Join(dataDir, "downloads", filename) + if _, err := os.Stat(path); os.IsNotExist(err) { + http.Error(w, "file not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + http.ServeFile(w, r, path) +} + +var httpClient = &http.Client{Timeout: 15 * time.Second} + +func proxyGet(w http.ResponseWriter, path string) { + req, err := http.NewRequest(http.MethodGet, headscaleURL+path, nil) + if err != nil { + httpError(w, "request error", 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)) + req, err := http.NewRequest(http.MethodPost, headscaleURL+path, bytes.NewReader(body)) if err != nil { - httpError(w, "failed to create request", err) + httpError(w, "request error", 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) + req, err := http.NewRequest(http.MethodDelete, headscaleURL+path, nil) if err != nil { - httpError(w, "failed to create request", err) + httpError(w, "request error", 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) @@ -287,7 +854,11 @@ func proxyDelete(w http.ResponseWriter, path string) { 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)}) + jsonResp(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)}) +} + +func jsonResp(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) } diff --git a/web-admin/static/app.js b/web-admin/static/app.js index 0df1891aa..74d8a2b5a 100644 --- a/web-admin/static/app.js +++ b/web-admin/static/app.js @@ -1,328 +1,718 @@ -// Headscale Web Admin - Frontend +// Tailscale Custom - Web Admin Frontend -let adminToken = ''; +let authToken = ''; +let currentUser = { username: '', role: '' }; let nodesData = []; let usersData = []; +let accountsData = []; +let refreshTimer = null; -// --- Auth --- +// ==================== 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'; +async function doLogin() { + const username = document.getElementById('login-username').value.trim(); + const password = document.getElementById('login-password').value; + const errEl = document.getElementById('login-error'); + errEl.style.display = 'none'; + + if (!username || !password) { + errEl.textContent = 'Please enter username and password'; + errEl.style.display = 'block'; + return; + } + + try { + const resp = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) }); + const data = await resp.json(); + if (!resp.ok || !data.ok) { + errEl.textContent = data.error || 'Login failed'; + errEl.style.display = 'block'; + return; + } + authToken = data.token; + currentUser = { username: data.username, role: data.role }; + sessionStorage.setItem('authToken', authToken); + sessionStorage.setItem('authUser', JSON.stringify(currentUser)); + enterApp(); + } catch (e) { + errEl.textContent = 'Connection error'; + errEl.style.display = 'block'; + } } function doLogout() { - adminToken = ''; - sessionStorage.removeItem('token'); - document.getElementById('app').style.display = 'none'; + api('/api/auth/logout', 'POST').catch(() => {}); + authToken = ''; + currentUser = { username: '', role: '' }; + sessionStorage.removeItem('authToken'); + sessionStorage.removeItem('authUser'); + if (refreshTimer) clearInterval(refreshTimer); + document.getElementById('main-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(); +function enterApp() { + document.getElementById('login-screen').style.display = 'none'; + document.getElementById('main-app').style.display = 'flex'; + + // Update user display + document.getElementById('user-display').textContent = currentUser.username; + document.getElementById('user-avatar').textContent = currentUser.username.charAt(0).toUpperCase(); + const badge = document.getElementById('user-role-badge'); + badge.textContent = currentUser.role; + badge.className = 'role-badge role-' + currentUser.role; + + // Show/hide tabs based on role + const isAdmin = currentUser.role === 'admin'; + document.querySelectorAll('.admin-only').forEach(el => el.style.display = isAdmin ? '' : 'none'); + document.querySelectorAll('.user-only').forEach(el => el.style.display = isAdmin ? 'none' : ''); + + // Activate first visible tab + const firstTab = document.querySelector('.nav-item[style=""], .nav-item:not([style])'); + if (firstTab) switchTab(firstTab.dataset.tab); + + // Auto-refresh + refreshAll(); + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = setInterval(refreshAll, 30000); +} + +// ==================== API helper ==================== + +async function api(url, method = 'GET', body = null) { + const opts = { + method, + headers: { 'Authorization': 'Bearer ' + authToken, 'Content-Type': 'application/json' } + }; + if (body) opts.body = JSON.stringify(body); + const resp = await fetch(url, opts); + if (resp.status === 401) { + doLogout(); + throw new Error('Session expired'); } -})(); + return resp; +} -// --- API helpers --- +// ==================== Tab switching ==================== -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); +document.addEventListener('click', (e) => { + const item = e.target.closest('.nav-item'); + if (item) switchTab(item.dataset.tab); +}); + +function switchTab(tab) { + document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.tab === tab)); + document.querySelectorAll('.tab-content').forEach(el => el.style.display = el.id === 'tab-' + tab ? '' : 'none'); + + // Refresh data for this tab + switch (tab) { + case 'dashboard': refreshDashboard(); break; + case 'nodes': refreshNodes(); break; + case 'accounts': refreshAccounts(); break; + case 'users': refreshUsers(); break; + case 'keys': refreshKeys(); break; + case 'my-nodes': refreshMyNodes(); break; + case 'download': refreshDownloads(); break; } - 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 --- +// ==================== Refresh all ==================== function refreshAll() { - refreshNodes(); - refreshUsers(); - refreshKeys(); + if (currentUser.role === 'admin') { + refreshDashboard(); + refreshNodes(); + } else { + refreshMyNodes(); + } } -// --- Nodes --- +// ==================== Dashboard (admin) ==================== -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')); +async function refreshDashboard() { + try { + const [nodesResp, usersResp] = await Promise.all([ + api('/api/admin/nodes'), + api('/api/admin/users') + ]); + const nodesJson = await nodesResp.json(); + const usersJson = await usersResp.json(); + const nodes = nodesJson.nodes || []; + const users = usersJson.users || []; + + const online = nodes.filter(n => isOnline(n)).length; + const total = nodes.length; + + document.getElementById('stats-grid').innerHTML = ` +
+
${total}
+
Total Nodes
+
+
+
${online}
+
Online
+
+
+
${total - online}
+
Offline
+
+
+
${users.length}
+
Users
+
+ `; + } catch (e) { console.error('dashboard', e); } } -function renderNodes(nodes) { - const tbody = document.getElementById('nodes-table'); - if (!nodes.length) { - tbody.innerHTML = 'No nodes registered'; +// ==================== Nodes (admin) ==================== + +async function refreshNodes() { + try { + const resp = await api('/api/admin/nodes'); + const data = await resp.json(); + nodesData = data.nodes || []; + renderNodes(); + } catch (e) { console.error('nodes', e); } +} + +function renderNodes() { + const tbody = document.querySelector('#nodes-table tbody'); + if (!nodesData.length) { + tbody.innerHTML = 'No nodes found'; 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 || '-') : '-'; + tbody.innerHTML = nodesData.map(n => { + const on = isOnline(n); + const ip = (n.ipAddresses || [])[0] || '-'; + const user = n.user?.name || '-'; + const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never'; const name = n.givenName || n.name || '-'; return ` - ${n.id} ${esc(name)} - ${esc(ips)} - ${esc(userName)} - ${online ? 'Online' : 'Offline'} - ${timeAgo(n.lastSeen)} - ${fmtDate(n.createdAt)} + ${esc(ip)} + ${esc(user)} + ${on ? 'Online' : 'Offline'} + ${lastSeen} - - + `; }).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'; +async function deleteNode(id, name) { + if (!confirm(`Delete node "${name}"?`)) return; + try { + await api('/api/admin/nodes/' + id, 'DELETE'); + toast('Node deleted'); + refreshNodes(); + refreshDashboard(); + } catch (e) { toast('Error: ' + e.message, true); } } -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')); +// ==================== Accounts (admin) ==================== + +async function refreshAccounts() { + try { + const resp = await api('/api/admin/accounts'); + const data = await resp.json(); + accountsData = data.accounts || []; + renderAccounts(); + } catch (e) { console.error('accounts', e); } } -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'; +function renderAccounts() { + const tbody = document.querySelector('#accounts-table tbody'); + if (!accountsData.length) { + tbody.innerHTML = 'No accounts'; return; } - tbody.innerHTML = users.map(u => { - const name = u.name || u.Name || '-'; + tbody.innerHTML = accountsData.map(a => { + const created = a.createdAt ? new Date(a.createdAt).toLocaleString() : '-'; + const isAdmin = a.username === 'admin'; return ` - ${u.id} - ${esc(name)} - ${fmtDate(u.createdAt)} - + ${esc(a.username)} + ${a.role} + ${created} + + + ${isAdmin ? '' : ``} + `; }).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')); +async function deleteAccount(username) { + if (!confirm(`Delete account "${username}"? This will also delete the Headscale user.`)) return; + try { + const resp = await api('/api/admin/accounts/' + username, 'DELETE'); + if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } + toast('Account deleted'); + refreshAccounts(); + refreshUsers(); + } catch (e) { toast('Error: ' + e.message, true); } } -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')); +function showCreateAccountModal() { + openModal('Create Account', ` +
+ + +
+
+ + +
+
+ + +
+ + `); } -// --- Pre-auth Keys --- +async function createAccount() { + const username = document.getElementById('new-acct-username').value.trim(); + const password = document.getElementById('new-acct-password').value; + const role = document.getElementById('new-acct-role').value; + if (!username || !password) { toast('Fill all fields', true); return; } + try { + const resp = await api('/api/admin/accounts', 'POST', { username, password, role }); + const data = await resp.json(); + if (!resp.ok) { toast(data.error || 'Failed', true); return; } + toast('Account created'); + closeModal(); + refreshAccounts(); + } catch (e) { toast('Error: ' + e.message, true); } +} -function updateKeyUserSelect(users) { - const sel = document.getElementById('key-user-select'); - sel.innerHTML = users.map(u => { - const name = u.name || u.Name; - return ``; +function showResetPasswordModal(username) { + openModal('Reset Password: ' + username, ` +
+ + +
+ + `); +} + +async function resetPassword(username) { + const password = document.getElementById('reset-password').value; + if (!password) { toast('Enter password', true); return; } + try { + const resp = await api('/api/admin/accounts/' + username + '/password', 'POST', { password }); + const data = await resp.json(); + if (!resp.ok) { toast(data.error || 'Failed', true); return; } + toast('Password reset'); + closeModal(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +// ==================== Headscale Users (admin) ==================== + +async function refreshUsers() { + try { + const resp = await api('/api/admin/users'); + const data = await resp.json(); + usersData = data.users || []; + renderUsers(); + } catch (e) { console.error('users', e); } +} + +function renderUsers() { + const tbody = document.querySelector('#users-table tbody'); + if (!usersData.length) { + tbody.innerHTML = 'No users'; + return; + } + tbody.innerHTML = usersData.map(u => { + const created = u.createdAt ? new Date(u.createdAt).toLocaleString() : '-'; + return ` + ${esc(u.name)} + ${created} + + + + + `; }).join(''); } -function refreshKeys() { - // Need to fetch keys per user - if (!usersData.length) { - api('/api/users').then(data => { - usersData = data.users || []; - fetchAllKeys(); - }); - } else { - fetchAllKeys(); - } +function showCreateUserModal() { + openModal('Create Headscale User', ` +
+ + +
+
+ + +
+

A login account will also be created so this user can access the admin panel.

+ + `); } -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(() => []); - }); +async function createUser() { + const name = document.getElementById('new-hs-user').value.trim(); + const password = document.getElementById('new-hs-password').value; + if (!name) { toast('Enter username', true); return; } + if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; } + try { + const resp = await api('/api/admin/users', 'POST', { name, password }); + if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } + toast('User created'); + closeModal(); + refreshUsers(); + refreshAccounts(); + } catch (e) { toast('Error: ' + e.message, true); } +} - Promise.all(promises).then(results => { - const allKeys = results.flat().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); +async function deleteUser(name) { + if (!confirm(`Delete Headscale user "${name}"? This will also delete the login account.`)) return; + try { + const resp = await api('/api/admin/users/' + name, 'DELETE'); + if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } + toast('User deleted'); + refreshUsers(); + refreshAccounts(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +function showResetUserPasswordModal(username) { + openModal('Reset Password: ' + username, ` +
+ + +
+ + `); +} + +async function resetUserPassword(username) { + const password = document.getElementById('reset-user-password').value; + if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; } + try { + const resp = await api('/api/admin/users/' + username + '/password', 'POST', { password }); + const data = await resp.json(); + if (!resp.ok) { toast(data.error || 'Failed', true); return; } + toast('Password reset'); + closeModal(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +// ==================== Keys (admin) ==================== + +async function refreshKeys() { + try { + // Get keys for all users + const usersResp = await api('/api/admin/users'); + const usersJson = await usersResp.json(); + const users = usersJson.users || []; + + let allKeys = []; + for (const u of users) { + try { + const keysResp = await api('/api/admin/preauthkeys?user=' + u.name); + const keysJson = await keysResp.json(); + const keys = keysJson.preAuthKeys || []; + keys.forEach(k => k._user = u.name); + allKeys = allKeys.concat(keys); + } catch (e) { /* skip */ } + } renderKeys(allKeys); - }); + } catch (e) { console.error('keys', e); } } function renderKeys(keys) { - const tbody = document.getElementById('keys-table'); + const tbody = document.querySelector('#keys-table tbody'); if (!keys.length) { - tbody.innerHTML = 'No pre-auth keys'; + tbody.innerHTML = 'No 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) + '...' : '-'; + const exp = k.expiration ? new Date(k.expiration).toLocaleString() : '-'; return ` - ${esc(keyShort)} + ${esc((k.key || '').substring(0, 12))}... ${esc(k._user || k.user || '-')} - ${k.reusable ? '✅' : '—'} - ${k.ephemeral ? '✅' : '—'} - ${k.used ? '✅' : '—'} - ${expired ? 'Expired' : fmtDate(k.expiration)} - ${fmtDate(k.createdAt)} + ${k.reusable ? '✅' : '❌'} + ${k.ephemeral ? '✅' : '❌'} + ${k.used ? '✅' : '❌'} + ${exp} `; }).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; +function showCreateKeyModal() { + // Need users list for dropdown + const usersOpts = usersData.map(u => ``).join(''); + openModal('Create Pre-Auth Key', ` +
+ + +
+
+ +
+
+ +
+
+ + +
+ + `); +} +async function createKey() { + const user = document.getElementById('new-key-user').value; + const reusable = document.getElementById('new-key-reusable').checked; + const ephemeral = document.getElementById('new-key-ephemeral').checked; + const hours = parseInt(document.getElementById('new-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) + '...'); + try { + const resp = await api('/api/admin/preauthkeys', 'POST', { user, reusable, ephemeral, expiration }); + if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } + toast('Key created'); + closeModal(); refreshKeys(); - }).catch(e => toast('Create key failed: ' + e.message, 'error')); + } catch (e) { toast('Error: ' + e.message, true); } } -function copyKey(key) { - navigator.clipboard.writeText(key).then( - () => toast('Key copied to clipboard'), - () => toast('Copy failed', 'error') - ); +// ==================== Register Node (admin) ==================== + +function showRegisterModal() { + const usersOpts = usersData.length + ? usersData.map(u => ``).join('') + : ''; + openModal('Register Node', ` +
+ + +
+
+ + +
+

The registration key is shown when the client tries to connect.

+ + `); } -// --- Helpers --- +async function registerNode() { + const user = document.getElementById('reg-user').value; + let key = document.getElementById('reg-key').value.trim(); + if (!user || !key) { toast('Fill all fields', true); return; } + key = extractKey(key); + try { + const resp = await api('/api/admin/register', 'POST', { user, key }); + const data = await resp.json(); + if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; } + toast('Node registered!'); + closeModal(); + refreshNodes(); + refreshDashboard(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +function extractKey(input) { + // Extract nodekey from URL or text + const match = input.match(/key=([a-f0-9]+)/i); + if (match) return match[1]; + // Remove nodekey: or mkey: prefix + return input.replace(/^(nodekey:|mkey:)/, ''); +} + +// ==================== My Nodes (user) ==================== + +async function refreshMyNodes() { + try { + const resp = await api('/api/user/nodes'); + const data = await resp.json(); + const nodes = data.nodes || []; + renderMyNodes(nodes); + } catch (e) { console.error('my-nodes', e); } +} + +function renderMyNodes(nodes) { + const tbody = document.querySelector('#my-nodes-table tbody'); + if (!nodes.length) { + tbody.innerHTML = 'No nodes registered. Click "+ Register Node" to add one.'; + return; + } + tbody.innerHTML = nodes.map(n => { + const on = isOnline(n); + const ip = (n.ipAddresses || [])[0] || '-'; + const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never'; + const name = n.givenName || n.name || '-'; + return ` + ${esc(name)} + ${esc(ip)} + ${on ? 'Online' : 'Offline'} + ${lastSeen} + `; + }).join(''); +} + +function showUserRegisterModal() { + openModal('Register My Node', ` +
+ + +
+

Open Tailscale Custom client → it will show a registration URL. Copy the key from there and paste it here.

+ + `); +} + +async function userRegisterNode() { + let key = document.getElementById('user-reg-key').value.trim(); + if (!key) { toast('Enter registration key', true); return; } + key = extractKey(key); + try { + const resp = await api('/api/user/register', 'POST', { key }); + const data = await resp.json(); + if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; } + toast('Node registered!'); + closeModal(); + refreshMyNodes(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +// ==================== Downloads ==================== + +async function refreshDownloads() { + try { + const resp = await fetch('/download/'); + const data = await resp.json(); + const files = data.files || []; + const container = document.getElementById('downloads-list'); + + if (!files.length) { + container.innerHTML = '

No downloads available yet.

'; + return; + } + + container.innerHTML = files.map(f => { + const sizeStr = formatSize(f.size); + const icon = f.name.endsWith('.msi') ? '💿' : f.name.endsWith('.exe') ? '⚙' : '📄'; + return `
+
${icon}
+
+
${esc(f.name)}
+
${sizeStr}
+
+ Download +
`; + }).join(''); + } catch (e) { console.error('downloads', e); } +} + +// ==================== Change Password ==================== + +function showPasswordModal() { + openModal('Change Password', ` +
+ + +
+
+ + +
+ + `); +} + +async function changePassword() { + const oldPassword = document.getElementById('chg-old-pw').value; + const newPassword = document.getElementById('chg-new-pw').value; + if (!oldPassword || !newPassword) { toast('Fill all fields', true); return; } + try { + const resp = await api('/api/auth/password', 'POST', { oldPassword, newPassword }); + const data = await resp.json(); + if (!resp.ok) { toast(data.error || 'Failed', true); return; } + toast('Password changed'); + closeModal(); + } catch (e) { toast('Error: ' + e.message, true); } +} + +// ==================== Modal helpers ==================== + +function openModal(title, bodyHtml) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-body').innerHTML = bodyHtml; + document.getElementById('modal-overlay').style.display = 'flex'; +} + +function closeModal() { + document.getElementById('modal-overlay').style.display = 'none'; +} + +// ==================== Utility ==================== function isOnline(node) { - return node.online === true; + if (!node.lastSeen) return false; + const diff = Date.now() - new Date(node.lastSeen).getTime(); + return node.online === true || diff < 300000; } 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'; + if (diff < 60000) return 'just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; + return Math.floor(diff / 86400000) + '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 formatSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(1) + ' GB'; } -function esc(s) { - if (!s) return ''; - const div = document.createElement('div'); - div.textContent = String(s); - return div.innerHTML; +function esc(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; } -// Auto-refresh every 30s -setInterval(() => { - if (adminToken) refreshNodes(); -}, 30000); +function toast(msg, isError = false) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.className = 'toast' + (isError ? ' toast-error' : ' toast-success'); + el.style.display = 'block'; + setTimeout(() => { el.style.display = 'none'; }, 3000); +} + +// Enter key for login +document.getElementById('login-password').addEventListener('keyup', (e) => { if (e.key === 'Enter') doLogin(); }); +document.getElementById('login-username').addEventListener('keyup', (e) => { if (e.key === 'Enter') document.getElementById('login-password').focus(); }); + +// Auto-login from session +(function init() { + const token = sessionStorage.getItem('authToken'); + const user = sessionStorage.getItem('authUser'); + if (token && user) { + authToken = token; + currentUser = JSON.parse(user); + // Verify session is still valid + fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } }) + .then(r => { + if (r.ok) enterApp(); + else doLogout(); + }) + .catch(() => doLogout()); + } +})(); diff --git a/web-admin/static/index.html b/web-admin/static/index.html index 31ad9285f..5ed8bf994 100644 --- a/web-admin/static/index.html +++ b/web-admin/static/index.html @@ -1,132 +1,166 @@ - + -Headscale Admin +Tailscale Custom - Admin + -
+ -