feat: admin panel with user/account management, foreign keys, reset password, dark theme, Linux client support, DERP relay integration

This commit is contained in:
2026-04-11 11:39:12 +07:00
parent dd207d9936
commit 01cbe66ad4
7 changed files with 2053 additions and 600 deletions
+1
View File
@@ -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"]
+6
View File
@@ -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:
+680 -109
View File
@@ -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)
}
+625 -235
View File
@@ -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 = `
<div class="stat-card">
<div class="stat-value">${total}</div>
<div class="stat-label">Total Nodes</div>
</div>
<div class="stat-card stat-online">
<div class="stat-value">${online}</div>
<div class="stat-label">Online</div>
</div>
<div class="stat-card stat-offline">
<div class="stat-value">${total - online}</div>
<div class="stat-label">Offline</div>
</div>
<div class="stat-card">
<div class="stat-value">${users.length}</div>
<div class="stat-label">Users</div>
</div>
`;
} catch (e) { console.error('dashboard', e); }
}
function renderNodes(nodes) {
const tbody = document.getElementById('nodes-table');
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#64748b;padding:40px">No nodes registered</td></tr>';
// ==================== 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 = '<tr><td colspan="6" class="empty">No nodes found</td></tr>';
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 `<tr>
<td>${n.id}</td>
<td><strong>${esc(name)}</strong></td>
<td><code style="font-size:12px">${esc(ips)}</code></td>
<td>${esc(userName)}</td>
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
<td>${timeAgo(n.lastSeen)}</td>
<td>${fmtDate(n.createdAt)}</td>
<td><code>${esc(ip)}</code></td>
<td>${esc(user)}</td>
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
<td>${lastSeen}</td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteNode('${n.id}','${esc(name)}')">Delete</button>
<button class="btn btn-sm" onclick="expireNode('${n.id}')">Expire</button>
<button class="btn btn-xs btn-danger" onclick="deleteNode('${n.id}', '${esc(name)}')">Delete</button>
</td>
</tr>`;
}).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 `<tr>
<td><strong>${esc(name)}</strong></td>
<td><code style="font-size:12px">${esc(ips)}</code></td>
<td>${esc(userName)}</td>
<td>${online ? '<span class="badge badge-online">Online</span>' : '<span class="badge badge-offline">Offline</span>'}</td>
<td>${timeAgo(n.lastSeen)}</td>
</tr>`;
}).join('') || '<tr><td colspan="5" style="text-align:center;color:#64748b">No nodes yet</td></tr>';
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 = '<tr><td colspan="4" style="text-align:center;color:#64748b;padding:40px">No users</td></tr>';
function renderAccounts() {
const tbody = document.querySelector('#accounts-table tbody');
if (!accountsData.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No accounts</td></tr>';
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 `<tr>
<td>${u.id}</td>
<td><strong>${esc(name)}</strong></td>
<td>${fmtDate(u.createdAt)}</td>
<td><button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(name)}')">Delete</button></td>
<td><strong>${esc(a.username)}</strong></td>
<td><span class="role-badge role-${a.role}">${a.role}</span></td>
<td>${created}</td>
<td>
<button class="btn btn-xs btn-outline" onclick="showResetPasswordModal('${esc(a.username)}')">Reset Password</button>
${isAdmin ? '' : `<button class="btn btn-xs btn-danger" onclick="deleteAccount('${esc(a.username)}')">Delete</button>`}
</td>
</tr>`;
}).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', `
<div class="form-group">
<label>Username</label>
<input type="text" id="new-acct-username" placeholder="username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="new-acct-password" placeholder="min 4 characters">
</div>
<div class="form-group">
<label>Role</label>
<select id="new-acct-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<button class="btn btn-primary btn-block" onclick="createAccount()">Create</button>
`);
}
// --- 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 `<option value="${esc(name)}">${esc(name)}</option>`;
function showResetPasswordModal(username) {
openModal('Reset Password: ' + username, `
<div class="form-group">
<label>New Password</label>
<input type="password" id="reset-password" placeholder="min 4 characters">
</div>
<button class="btn btn-primary btn-block" onclick="resetPassword('${esc(username)}')">Reset</button>
`);
}
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 = '<tr><td colspan="3" class="empty">No users</td></tr>';
return;
}
tbody.innerHTML = usersData.map(u => {
const created = u.createdAt ? new Date(u.createdAt).toLocaleString() : '-';
return `<tr>
<td><strong>${esc(u.name)}</strong></td>
<td>${created}</td>
<td>
<button class="btn btn-xs btn-outline" onclick="showResetUserPasswordModal('${esc(u.name)}')">Reset Password</button>
<button class="btn btn-xs btn-danger" onclick="deleteUser('${esc(u.name)}')">Delete</button>
</td>
</tr>`;
}).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', `
<div class="form-group">
<label>Username</label>
<input type="text" id="new-hs-user" placeholder="username">
</div>
<div class="form-group">
<label>Password (for login account)</label>
<input type="password" id="new-hs-password" placeholder="min 4 characters">
</div>
<p class="help-text">A login account will also be created so this user can access the admin panel.</p>
<button class="btn btn-primary btn-block" onclick="createUser()">Create</button>
`);
}
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, `
<div class="form-group">
<label>New Password</label>
<input type="password" id="reset-user-password" placeholder="min 4 characters">
</div>
<button class="btn btn-primary btn-block" onclick="resetUserPassword('${esc(username)}')">Reset</button>
`);
}
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 = '<tr><td colspan="7" style="text-align:center;color:#64748b;padding:40px">No pre-auth keys</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="empty">No keys</td></tr>';
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 `<tr>
<td><span class="key-text" title="Click to copy full key" onclick="copyKey('${esc(k.key)}')">${esc(keyShort)}</span></td>
<td><code class="key-text">${esc((k.key || '').substring(0, 12))}...</code></td>
<td>${esc(k._user || k.user || '-')}</td>
<td>${k.reusable ? '' : ''}</td>
<td>${k.ephemeral ? '' : ''}</td>
<td>${k.used ? '' : ''}</td>
<td>${expired ? '<span class="badge badge-expired">Expired</span>' : fmtDate(k.expiration)}</td>
<td>${fmtDate(k.createdAt)}</td>
<td>${k.reusable ? '&#x2705;' : '&#x274C;'}</td>
<td>${k.ephemeral ? '&#x2705;' : '&#x274C;'}</td>
<td>${k.used ? '&#x2705;' : '&#x274C;'}</td>
<td>${exp}</td>
</tr>`;
}).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 => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('');
openModal('Create Pre-Auth Key', `
<div class="form-group">
<label>User</label>
<select id="new-key-user">${usersOpts}</select>
</div>
<div class="form-group">
<label><input type="checkbox" id="new-key-reusable"> Reusable</label>
</div>
<div class="form-group">
<label><input type="checkbox" id="new-key-ephemeral"> Ephemeral</label>
</div>
<div class="form-group">
<label>Expiration (hours)</label>
<input type="number" id="new-key-expiry" value="24" min="1">
</div>
<button class="btn btn-primary btn-block" onclick="createKey()">Create</button>
`);
}
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 => `<option value="${esc(u.name)}">${esc(u.name)}</option>`).join('')
: '<option value="">No users</option>';
openModal('Register Node', `
<div class="form-group">
<label>User</label>
<select id="reg-user">${usersOpts}</select>
</div>
<div class="form-group">
<label>Registration Key</label>
<input type="text" id="reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
</div>
<p class="help-text">The registration key is shown when the client tries to connect.</p>
<button class="btn btn-primary btn-block" onclick="registerNode()">Register</button>
`);
}
// --- 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 = '<tr><td colspan="4" class="empty">No nodes registered. Click "+ Register Node" to add one.</td></tr>';
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 `<tr>
<td><strong>${esc(name)}</strong></td>
<td><code>${esc(ip)}</code></td>
<td><span class="badge ${on ? 'badge-online' : 'badge-offline'}">${on ? 'Online' : 'Offline'}</span></td>
<td>${lastSeen}</td>
</tr>`;
}).join('');
}
function showUserRegisterModal() {
openModal('Register My Node', `
<div class="form-group">
<label>Registration Key</label>
<input type="text" id="user-reg-key" placeholder="Paste the nodekey:... or mkey:... from client">
</div>
<p class="help-text">Open Tailscale Custom client &#x2192; it will show a registration URL. Copy the key from there and paste it here.</p>
<button class="btn btn-primary btn-block" onclick="userRegisterNode()">Register</button>
`);
}
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 = '<div class="empty-state"><p>No downloads available yet.</p></div>';
return;
}
container.innerHTML = files.map(f => {
const sizeStr = formatSize(f.size);
const icon = f.name.endsWith('.msi') ? '&#x1F4BF;' : f.name.endsWith('.exe') ? '&#x2699;' : '&#x1F4C4;';
return `<div class="download-card">
<div class="download-icon">${icon}</div>
<div class="download-info">
<div class="download-name">${esc(f.name)}</div>
<div class="download-size">${sizeStr}</div>
</div>
<a href="/download/${encodeURIComponent(f.name)}" class="btn btn-primary btn-sm" download>Download</a>
</div>`;
}).join('');
} catch (e) { console.error('downloads', e); }
}
// ==================== Change Password ====================
function showPasswordModal() {
openModal('Change Password', `
<div class="form-group">
<label>Current Password</label>
<input type="password" id="chg-old-pw">
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="chg-new-pw" placeholder="min 4 characters">
</div>
<button class="btn btn-primary btn-block" onclick="changePassword()">Change</button>
`);
}
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());
}
})();
+141 -107
View File
@@ -1,132 +1,166 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Headscale Admin</title>
<title>Tailscale Custom - Admin</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Login screen -->
<div id="login-screen" class="login-screen">
<div id="login-screen" class="login-container">
<div class="login-box">
<h1>🔒 Headscale Admin</h1>
<input type="password" id="login-password" placeholder="Admin password" onkeydown="if(event.key==='Enter')doLogin()">
<button onclick="doLogin()">Login</button>
<p id="login-error" class="error"></p>
<div class="login-logo">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12l2 2 4-4"/></svg>
</div>
<h1>Tailscale Custom</h1>
<p class="login-subtitle">VPN Management Panel</p>
<div class="form-group">
<label>Username</label>
<input type="text" id="login-username" placeholder="admin" autofocus>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="login-password" placeholder="********">
</div>
<button class="btn btn-primary btn-block" onclick="doLogin()">Sign In</button>
<div id="login-error" class="error-msg" style="display:none"></div>
</div>
</div>
<!-- Main app -->
<div id="app" style="display:none">
<nav>
<div class="nav-brand">⚡ Headscale Admin</div>
<div class="nav-tabs">
<button class="tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
<button class="tab" data-tab="nodes" onclick="switchTab('nodes')">Nodes</button>
<button class="tab" data-tab="users" onclick="switchTab('users')">Users</button>
<button class="tab" data-tab="keys" onclick="switchTab('keys')">Auth Keys</button>
<div id="main-app" style="display:none">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-header">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#60a5fa" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 12l2 2 4-4"/></svg>
<span class="brand">Tailscale Custom</span>
</div>
<div class="sidebar-user">
<div class="avatar" id="user-avatar">A</div>
<div class="user-info">
<span class="username" id="user-display"></span>
<span class="role-badge" id="user-role-badge"></span>
</div>
</div>
<ul class="nav-links">
<!-- Admin tabs -->
<li class="nav-item admin-only" data-tab="dashboard"><span class="nav-icon">&#x2630;</span> Dashboard</li>
<li class="nav-item admin-only" data-tab="nodes"><span class="nav-icon">&#x25B6;</span> All Nodes</li>
<li class="nav-item admin-only" data-tab="accounts"><span class="nav-icon">&#x2605;</span> Accounts</li>
<li class="nav-item admin-only" data-tab="users"><span class="nav-icon">&#x2666;</span> Headscale Users</li>
<li class="nav-item admin-only" data-tab="keys"><span class="nav-icon">&#x25C6;</span> Auth Keys</li>
<!-- User tabs -->
<li class="nav-item user-only" data-tab="my-nodes"><span class="nav-icon">&#x25B6;</span> My Nodes</li>
<!-- Common -->
<li class="nav-item" data-tab="download"><span class="nav-icon">&#x2913;</span> Download Client</li>
</ul>
<div class="sidebar-footer">
<button class="btn btn-outline btn-sm" onclick="showPasswordModal()">&#x26BF; Change Password</button>
<button class="btn btn-danger btn-sm" onclick="doLogout()">Logout</button>
</div>
<button class="btn-logout" onclick="doLogout()">Logout</button>
</nav>
<!-- Dashboard -->
<div id="tab-dashboard" class="tab-content active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number" id="stat-total">-</div>
<div class="stat-label">Total Nodes</div>
</div>
<div class="stat-card stat-online">
<div class="stat-number" id="stat-online">-</div>
<div class="stat-label">Online</div>
</div>
<div class="stat-card stat-offline">
<div class="stat-number" id="stat-offline">-</div>
<div class="stat-label">Offline</div>
</div>
<div class="stat-card stat-users">
<div class="stat-number" id="stat-users">-</div>
<div class="stat-label">Users</div>
</div>
</div>
<div class="card">
<h3>Recent Nodes</h3>
<table>
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th></tr></thead>
<tbody id="dashboard-nodes"></tbody>
</table>
</div>
</div>
<!-- Content area -->
<main class="content">
<!-- Dashboard (admin) -->
<section id="tab-dashboard" class="tab-content">
<h2>Dashboard</h2>
<div class="stats-grid" id="stats-grid"></div>
</section>
<!-- Nodes -->
<div id="tab-nodes" class="tab-content">
<div class="toolbar">
<h2>Nodes</h2>
<div>
<button class="btn btn-primary" onclick="refreshNodes()">↻ Refresh</button>
<!-- All Nodes (admin) -->
<section id="tab-nodes" class="tab-content" style="display:none">
<div class="section-header">
<h2>All Nodes</h2>
<button class="btn btn-primary btn-sm" onclick="showRegisterModal()">+ Register Node</button>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>IP Addresses</th>
<th>User</th>
<th>Status</th>
<th>Last Seen</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="nodes-table"></tbody>
</table>
</div>
</div>
<!-- Users -->
<div id="tab-users" class="tab-content">
<div class="toolbar">
<h2>Users</h2>
<div>
<input type="text" id="new-user-name" placeholder="Username">
<button class="btn btn-primary" onclick="createUser()">+ Create User</button>
<button class="btn" onclick="refreshUsers()">↻ Refresh</button>
<div class="table-container">
<table id="nodes-table">
<thead><tr><th>Name</th><th>IP</th><th>User</th><th>Status</th><th>Last Seen</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>ID</th><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
<tbody id="users-table"></tbody>
</table>
</div>
</div>
</section>
<!-- Pre-auth Keys -->
<div id="tab-keys" class="tab-content">
<div class="toolbar">
<h2>Pre-Auth Keys</h2>
<div>
<select id="key-user-select"></select>
<label><input type="checkbox" id="key-reusable" checked> Reusable</label>
<label><input type="checkbox" id="key-ephemeral"> Ephemeral</label>
<input type="number" id="key-expiry" value="24" min="1" style="width:60px"> hours
<button class="btn btn-primary" onclick="createKey()">+ Create Key</button>
<button class="btn" onclick="refreshKeys()">↻ Refresh</button>
<!-- Accounts (admin) -->
<section id="tab-accounts" class="tab-content" style="display:none">
<div class="section-header">
<h2>Login Accounts</h2>
<button class="btn btn-primary btn-sm" onclick="showCreateAccountModal()">+ Create Account</button>
</div>
</div>
<div class="card">
<table>
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th><th>Created</th></tr></thead>
<tbody id="keys-table"></tbody>
</table>
</div>
</div>
<div class="table-container">
<table id="accounts-table">
<thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Toast -->
<div id="toast" class="toast"></div>
<!-- Headscale Users (admin) -->
<section id="tab-users" class="tab-content" style="display:none">
<div class="section-header">
<h2>Headscale Users</h2>
<button class="btn btn-primary btn-sm" onclick="showCreateUserModal()">+ Create User</button>
</div>
<div class="table-container">
<table id="users-table">
<thead><tr><th>Name</th><th>Created</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Auth Keys (admin) -->
<section id="tab-keys" class="tab-content" style="display:none">
<div class="section-header">
<h2>Pre-Auth Keys</h2>
<button class="btn btn-primary btn-sm" onclick="showCreateKeyModal()">+ Create Key</button>
</div>
<div class="table-container">
<table id="keys-table">
<thead><tr><th>Key</th><th>User</th><th>Reusable</th><th>Ephemeral</th><th>Used</th><th>Expiration</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- My Nodes (user) -->
<section id="tab-my-nodes" class="tab-content" style="display:none">
<div class="section-header">
<h2>My Nodes</h2>
<button class="btn btn-primary btn-sm" onclick="showUserRegisterModal()">+ Register Node</button>
</div>
<div class="table-container">
<table id="my-nodes-table">
<thead><tr><th>Name</th><th>IP</th><th>Status</th><th>Last Seen</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Download -->
<section id="tab-download" class="tab-content" style="display:none">
<h2>Download Client</h2>
<p class="section-desc">Download the Tailscale Custom VPN client for your platform.</p>
<div id="downloads-list" class="downloads-grid"></div>
</section>
</main>
</div>
<!-- Toast -->
<div id="toast" class="toast" style="display:none"></div>
<!-- Modals -->
<div id="modal-overlay" class="modal-overlay" style="display:none" onclick="closeModal()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h3 id="modal-title">Modal</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script src="app.js"></script>
+512 -114
View File
@@ -1,4 +1,4 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -7,133 +7,531 @@ body {
min-height: 100vh;
}
/* Login */
.login-screen {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; background: #0f172a;
/* ==================== Login ==================== */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.login-box {
background: #1e293b; padding: 40px; border-radius: 12px;
text-align: center; min-width: 320px;
box-shadow: 0 25px 50px rgba(0,0,0,.5);
}
.login-box h1 { margin-bottom: 24px; font-size: 1.5rem; }
.login-box input {
width: 100%; padding: 10px 14px; margin-bottom: 12px;
background: #0f172a; border: 1px solid #334155; border-radius: 6px;
color: #e2e8f0; font-size: 14px;
}
.login-box button {
width: 100%; padding: 10px; background: #3b82f6; color: #fff;
border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;
}
.login-box button:hover { background: #2563eb; }
.error { color: #ef4444; margin-top: 8px; font-size: 13px; }
/* Nav */
nav {
display: flex; align-items: center; gap: 16px;
padding: 0 24px; height: 56px;
background: #1e293b; border-bottom: 1px solid #334155;
}
.nav-brand { font-weight: 700; font-size: 1.1rem; margin-right: 24px; }
.nav-tabs { display: flex; gap: 4px; flex: 1; }
.tab {
padding: 8px 16px; background: transparent; color: #94a3b8;
border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
}
.tab:hover { background: #334155; color: #e2e8f0; }
.tab.active { background: #3b82f6; color: #fff; }
.btn-logout {
padding: 6px 12px; background: transparent; color: #94a3b8;
border: 1px solid #475569; border-radius: 6px; cursor: pointer; font-size: 12px;
}
.btn-logout:hover { background: #ef4444; color: #fff; border-color: #ef4444; }
/* Tab content */
.tab-content { display: none; padding: 24px; }
.tab-content.active { display: block; }
/* Stats */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card {
background: #1e293b; padding: 24px; border-radius: 10px;
border-left: 4px solid #3b82f6;
}
.stat-card.stat-online { border-left-color: #22c55e; }
.stat-card.stat-offline { border-left-color: #ef4444; }
.stat-card.stat-users { border-left-color: #a855f7; }
.stat-number { font-size: 2rem; font-weight: 700; }
.stat-label { color: #94a3b8; font-size: 13px; margin-top: 4px; }
/* Card */
.card {
background: #1e293b; border-radius: 10px; padding: 20px;
overflow-x: auto;
background: #1e293b;
border: 1px solid #334155;
border-radius: 16px;
padding: 48px 40px;
width: 400px;
max-width: 90vw;
text-align: center;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.4);
}
/* Table */
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left; padding: 10px 12px; font-size: 13px;
.login-logo { margin-bottom: 16px; }
.login-box h1 {
font-size: 24px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 4px;
}
.login-subtitle {
color: #94a3b8;
font-size: 14px;
margin-bottom: 32px;
}
.error-msg {
color: #f87171;
font-size: 13px;
margin-top: 12px;
padding: 8px;
background: rgba(248, 113, 113, 0.1);
border-radius: 6px;
}
/* ==================== Layout ==================== */
#main-app {
display: flex;
min-height: 100vh;
}
/* ==================== Sidebar ==================== */
.sidebar {
width: 260px;
background: #1e293b;
border-right: 1px solid #334155;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px;
border-bottom: 1px solid #334155;
}
th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
tr:hover { background: #263248; }
/* Toolbar */
.toolbar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
.brand {
font-size: 16px;
font-weight: 700;
color: #f1f5f9;
}
.toolbar h2 { font-size: 1.2rem; }
.toolbar div { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toolbar input, .toolbar select {
padding: 6px 10px; background: #0f172a; border: 1px solid #334155;
border-radius: 6px; color: #e2e8f0; font-size: 13px;
}
.toolbar label { font-size: 13px; color: #94a3b8; display: flex; align-items: center; gap: 4px; }
/* Buttons */
.btn {
padding: 6px 14px; border: 1px solid #475569; background: transparent;
color: #e2e8f0; border-radius: 6px; cursor: pointer; font-size: 13px;
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #334155;
}
.btn:hover { background: #334155; }
.btn-primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
.btn-primary:hover { background: #2563eb; }
.btn-danger { background: #ef4444; border-color: #ef4444; color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* Status badge */
.badge {
display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600;
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
color: white;
}
.badge-online { background: #166534; color: #4ade80; }
.badge-offline { background: #7f1d1d; color: #fca5a5; }
.badge-expired { background: #78350f; color: #fcd34d; }
/* Toast */
.toast {
position: fixed; bottom: 24px; right: 24px;
background: #1e293b; border: 1px solid #334155; border-radius: 8px;
padding: 12px 20px; font-size: 13px; opacity: 0;
transition: opacity .3s; pointer-events: none;
z-index: 1000; max-width: 400px;
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.toast.show { opacity: 1; pointer-events: auto; }
.toast.error { border-color: #ef4444; }
.toast.success { border-color: #22c55e; }
/* Key display */
.key-text {
font-family: monospace; font-size: 11px; background: #0f172a;
padding: 2px 6px; border-radius: 4px; word-break: break-all;
.username {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.role-badge {
display: inline-block;
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
font-weight: 500;
width: fit-content;
}
.role-admin {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
}
.role-user {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.nav-links {
list-style: none;
padding: 12px 0;
flex: 1;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
cursor: pointer;
color: #94a3b8;
font-size: 14px;
transition: all 0.15s;
}
.nav-item:hover {
background: rgba(59, 130, 246, 0.1);
color: #e2e8f0;
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border-right: 3px solid #3b82f6;
}
.nav-icon { font-size: 16px; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid #334155;
display: flex;
flex-direction: column;
gap: 8px;
}
/* ==================== Content ==================== */
.content {
flex: 1;
padding: 32px;
overflow-y: auto;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.section-header h2 { margin: 0; }
.section-desc {
color: #94a3b8;
margin-bottom: 20px;
}
h2 {
font-size: 22px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 20px;
}
/* ==================== Forms ==================== */
.form-group {
margin-bottom: 16px;
text-align: left;
}
.form-group label {
display: block;
font-size: 13px;
color: #94a3b8;
margin-bottom: 6px;
font-weight: 500;
}
input[type="text"],
input[type="password"],
input[type="number"],
select {
width: 100%;
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: #3b82f6;
}
/* ==================== Buttons ==================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover { background: #2563eb; }
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover { background: #dc2626; }
.btn-outline {
background: transparent;
border: 1px solid #475569;
color: #94a3b8;
}
.btn-outline:hover {
border-color: #60a5fa;
color: #60a5fa;
}
.btn-block { width: 100%; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-xs { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
/* ==================== Stats ==================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 24px;
text-align: center;
}
.stat-card.stat-online { border-color: #22c55e; }
.stat-card.stat-offline { border-color: #ef4444; }
.stat-value {
font-size: 36px;
font-weight: 700;
color: #f1f5f9;
}
.stat-label {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
}
/* ==================== Tables ==================== */
.table-container {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #0f172a;
border-bottom: 1px solid #334155;
}
td {
padding: 12px 16px;
font-size: 14px;
border-bottom: 1px solid #1e293b;
}
tr:hover td { background: rgba(59, 130, 246, 0.05); }
td.empty {
text-align: center;
color: #64748b;
padding: 32px;
}
code {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 13px;
color: #60a5fa;
}
.key-text {
font-size: 12px;
color: #94a3b8;
}
/* ==================== Badges ==================== */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-online {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.badge-offline {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
/* ==================== Downloads ==================== */
.downloads-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.download-card {
display: flex;
align-items: center;
gap: 16px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 20px;
}
.download-icon { font-size: 32px; }
.download-info { flex: 1; }
.download-name {
font-weight: 600;
color: #f1f5f9;
font-size: 14px;
}
.download-size {
color: #94a3b8;
font-size: 13px;
margin-top: 2px;
}
.empty-state {
text-align: center;
color: #64748b;
padding: 48px 24px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
}
/* ==================== Toast ==================== */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.toast-success {
background: #22c55e;
color: white;
}
.toast-error {
background: #ef4444;
color: white;
}
/* ==================== Modal ==================== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9000;
}
.modal {
background: #1e293b;
border: 1px solid #334155;
border-radius: 16px;
padding: 0;
width: 440px;
max-width: 90vw;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #334155;
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
}
.modal-close {
background: none;
border: none;
color: #94a3b8;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover { color: #e2e8f0; }
.modal-body { padding: 24px; }
.help-text {
font-size: 12px;
color: #64748b;
margin-bottom: 16px;
}
/* ==================== Responsive ==================== */
@media (max-width: 768px) {
#main-app { flex-direction: column; }
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #334155;
}
.nav-links {
display: flex;
overflow-x: auto;
padding: 0;
}
.nav-item {
white-space: nowrap;
border-right: none;
padding: 12px 16px;
}
.nav-item.active { border-right: none; border-bottom: 3px solid #3b82f6; }
.sidebar-footer { flex-direction: row; }
.content { padding: 20px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
.key-text:hover { background: #334155; }