865 lines
25 KiB
Go
865 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
var (
|
|
headscaleURL string
|
|
apiKey string
|
|
listenAddr 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")
|
|
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)
|
|
|
|
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.Fatal(http.ListenAndServe(listenAddr, mux))
|
|
}
|
|
|
|
func getEnv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
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) {
|
|
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 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)
|
|
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,
|
|
})
|
|
}
|
|
|
|
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 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:
|
|
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:
|
|
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 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 {
|
|
switch parts[1] {
|
|
case "expire":
|
|
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/expire", nodeID))
|
|
case "rename":
|
|
proxyPost(w, r, fmt.Sprintf("/api/v1/node/%s/rename", nodeID))
|
|
case "routes":
|
|
proxyGet(w, fmt.Sprintf("/api/v1/node/%s/routes", nodeID))
|
|
default:
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
if r.Method == http.MethodDelete {
|
|
proxyDelete(w, fmt.Sprintf("/api/v1/node/%s", nodeID))
|
|
} else {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
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 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")
|
|
u := "/api/v1/preauthkey"
|
|
if user != "" {
|
|
u += "?user=" + user
|
|
}
|
|
proxyGet(w, u)
|
|
case http.MethodPost:
|
|
proxyPost(w, r, "/api/v1/preauthkey")
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func handleAdminRoutes(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
proxyGet(w, "/api/v1/routes")
|
|
} else {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func 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 {
|
|
switch parts[1] {
|
|
case "enable":
|
|
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/enable", routeID))
|
|
case "disable":
|
|
proxyPost(w, r, fmt.Sprintf("/api/v1/routes/%s/disable", routeID))
|
|
default:
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
}
|
|
return
|
|
}
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
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) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, err := http.NewRequest(http.MethodPost, headscaleURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
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) {
|
|
req, err := http.NewRequest(http.MethodDelete, 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 httpError(w http.ResponseWriter, msg string, err error) {
|
|
log.Printf("ERROR: %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)
|
|
}
|