Files
huanld 2fb067ecbf
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
natlab-integrationtest / natlab-integrationtest (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (benchmarks) (push) Has been cancelled
CI / Windows (1/2) (push) Has been cancelled
CI / Windows (2/2) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / fuzz (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (macOS) (push) Has been cancelled
CI / staticcheck (Linux) (push) Has been cancelled
CI / staticcheck (Windows) (push) Has been cancelled
CI / staticcheck (Portable (1/4)) (push) Has been cancelled
CI / staticcheck (Portable (2/4)) (push) Has been cancelled
CI / staticcheck (Portable (3/4)) (push) Has been cancelled
CI / staticcheck (Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
test installer.sh / test (curl, alpine:3.21) (push) Has been cancelled
test installer.sh / test (curl, alpine:edge) (push) Has been cancelled
test installer.sh / test (curl, alpine:latest) (push) Has been cancelled
test installer.sh / test (curl, amazonlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, archlinux:latest) (push) Has been cancelled
test installer.sh / test (curl, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:sid-slim) (push) Has been cancelled
test installer.sh / test (curl, debian:stable-slim, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, debian:testing-slim) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:stable) (push) Has been cancelled
test installer.sh / test (curl, elementary/docker:unstable) (push) Has been cancelled
test installer.sh / test (curl, fedora:latest, 1.80.0) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-dev) (push) Has been cancelled
test installer.sh / test (curl, kalilinux/kali-rolling) (push) Has been cancelled
test installer.sh / test (curl, opensuse/leap:latest) (push) Has been cancelled
test installer.sh / test (curl, opensuse/tumbleweed:latest) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:8) (push) Has been cancelled
test installer.sh / test (curl, oraclelinux:9) (push) Has been cancelled
test installer.sh / test (curl, parrotsec/core:latest) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:8.7) (push) Has been cancelled
test installer.sh / test (curl, rockylinux:9) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:20.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:22.04) (push) Has been cancelled
test installer.sh / test (curl, ubuntu:24.04, 1.80.0) (push) Has been cancelled
test installer.sh / test (wget, debian:oldstable-slim) (push) Has been cancelled
test installer.sh / test (wget, debian:sid-slim) (push) Has been cancelled
update-flake / update-flake (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
test installer.sh / notify-slack (push) Has been cancelled
feat: security hardening, production roadmap, admin panel v1
Client security fixes (cmd/tailscale-tray/main.go):
- SSRF protection in Add Server dialog (validateControlURL): reject
  private/loopback/link-local/cloud-metadata IPs via DNS resolution
- RCE gate on AuthURL/BrowseToURL exec paths (validateAuthURL)
- Sanitized URL logging (sanitizeURLForLog drops query auth tokens)
- Error handling on exec.Command with user-facing showError()

Admin panel security (web-admin):
- Bcrypt password hashing (replaces SHA256)
- Rate limiting: 5 failed logins → 15-min lockout
- Session + login attempt cleanup goroutine (hourly)
- url.QueryEscape / encodeURIComponent for all API params
- Fail-hard startup when no TLS and non-loopback bind
- ADMIN_PASSWORD required (no default), password min 12 chars
- Username regex whitelist

Installer hardening (Setup.wxs):
- util:PermissionEx restricts SCM access: only Administrators +
  SYSTEM can start/stop/reconfigure service. Authenticated Users
  limited to QueryStatus/QueryConfig/Interrogate
- Vital="yes" on ServiceInstall

Docs & roadmap:
- PRODUCTION_ROADMAP.md: 5-milestone plan (security + features +
  distribution + ops) with granular tasks, effort, done-when
- CLIENT_SECURITY_AUDIT.md, SECURITY_FIXES.md, DEPLOYMENT.md
- AI assistant rules (.cursorrules, .antigravityrules, etc.)

Build & distribution:
- build-msi.ps1, deploy-and-sign.ps1, sign-release.ps1
- redeploy.ps1, tray-deploy.ps1, test-msi.ps1
- installer/msi/ alternative WXS setup
- Restored .github/workflows/ removed in mirror cleanup

.gitignore hardened: *.pfx, *.p12, *.key, *.pem, .env*
2026-04-22 15:18:11 +07:00

1031 lines
29 KiB
Go

package main
import (
"bytes"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
//go:embed static/*
var staticFS embed.FS
var (
headscaleURL string
apiKey string
listenAddr string
dataDir string
tlsCertFile string
tlsKeyFile string
)
type AppUser struct {
Username string `json:"username"`
PassHash string `json:"passHash"`
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 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
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 string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashed), nil
}
func verifyPassword(user *AppUser, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password))
return err == nil
}
func isValidUsername(username string) bool {
if len(username) < 1 || len(username) > 63 {
return false
}
for _, ch := range username {
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.') {
return false
}
}
return true
}
func extractBearerToken(r *http.Request) string {
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
func validatePasswordLength(password string) error {
if len(password) < 12 {
return fmt.Errorf("password too short (min 12)")
}
return nil
}
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)}
loginAttempts = struct {
mu sync.RWMutex
data map[string]*loginAttempt
}{data: make(map[string]*loginAttempt)}
)
type loginAttempt struct {
count int
lastTime time.Time
lockedAt time.Time
}
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 cleanupExpiredSessions() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
sessions.mu.Lock()
now := time.Now()
for token, sess := range sessions.data {
if now.Sub(sess.Created) > 24*time.Hour {
delete(sessions.data, token)
}
}
sessions.mu.Unlock()
loginAttempts.mu.Lock()
for username, attempt := range loginAttempts.data {
if !attempt.lockedAt.IsZero() && now.Sub(attempt.lockedAt) > 15*time.Minute {
delete(loginAttempts.data, username)
} else if attempt.lockedAt.IsZero() && now.Sub(attempt.lastTime) > 5*time.Minute {
delete(loginAttempts.data, username)
}
}
loginAttempts.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")
tlsCertFile = getEnv("TLS_CERT_FILE", "")
tlsKeyFile = getEnv("TLS_KEY_FILE", "")
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()
go cleanupExpiredSessions()
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)
if tlsCertFile != "" && tlsKeyFile != "" {
log.Printf("Using TLS with certificate: %s", tlsCertFile)
log.Fatal(http.ListenAndServeTLS(listenAddr, tlsCertFile, tlsKeyFile, mux))
}
// No TLS configured: refuse to bind to anything other than loopback,
// otherwise admin passwords would transit plaintext over the wire.
if !isLoopbackListen(listenAddr) {
log.Fatalf("TLS_CERT_FILE/TLS_KEY_FILE are required when LISTEN_ADDR (%q) is not loopback. "+
"Set both vars, or bind to 127.0.0.1:<port> and put a TLS reverse proxy in front.", listenAddr)
}
log.Println("WARNING: Running without TLS on loopback only. Put a reverse proxy (nginx/caddy) in front for remote access.")
log.Fatal(http.ListenAndServe(listenAddr, mux))
}
// isLoopbackListen reports whether addr (host:port) binds only to loopback.
// Empty host or ":port" binds to all interfaces → not loopback.
func isLoopbackListen(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil || host == "" {
return false
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
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 := os.Getenv("ADMIN_PASSWORD")
if adminPass == "" {
log.Fatal("ADMIN_PASSWORD environment variable is required for initial setup")
}
hash, err := hashPassword(adminPass)
if err != nil {
log.Fatalf("Failed to hash admin password: %v", err)
}
userDB.Add(AppUser{
Username: "admin",
PassHash: hash,
Role: "admin",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
})
log.Println("Created admin user")
}
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
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
}
bodyBytes, _ := io.ReadAll(r.Body)
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
json.Unmarshal(bodyBytes, &req)
req.Username = strings.TrimSpace(req.Username)
// Check rate limiting
loginAttempts.mu.Lock()
attempt := loginAttempts.data[req.Username]
if attempt == nil {
attempt = &loginAttempt{}
loginAttempts.data[req.Username] = attempt
}
if !attempt.lockedAt.IsZero() && time.Since(attempt.lockedAt) < 15*time.Minute {
loginAttempts.mu.Unlock()
jsonResp(w, http.StatusTooManyRequests, map[string]string{"error": "account temporarily locked due to too many failed attempts"})
return
}
if attempt.lockedAt.IsZero() && attempt.count >= 5 && time.Since(attempt.lastTime) < 5*time.Minute {
attempt.lockedAt = time.Now()
loginAttempts.mu.Unlock()
jsonResp(w, http.StatusTooManyRequests, map[string]string{"error": "account temporarily locked due to too many failed attempts"})
return
}
if time.Since(attempt.lastTime) > 5*time.Minute {
attempt.count = 0
attempt.lockedAt = time.Time{}
}
loginAttempts.mu.Unlock()
user := userDB.Find(req.Username)
if user == nil || !verifyPassword(user, req.Password) {
loginAttempts.mu.Lock()
attempt := loginAttempts.data[req.Username]
if attempt != nil {
attempt.count++
attempt.lastTime = time.Now()
}
loginAttempts.mu.Unlock()
jsonResp(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
// Clear login attempts on successful login
loginAttempts.mu.Lock()
loginAttempts.data[req.Username] = &loginAttempt{}
loginAttempts.mu.Unlock()
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 := extractBearerToken(r)
deleteSession(token)
jsonResp(w, http.StatusOK, map[string]bool{"ok": true})
}
func handleMe(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
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 err := validatePasswordLength(req.NewPassword); err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
hash, err := hashPassword(req.NewPassword)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password hashing failed"})
return
}
userDB.ChangePassword(username, hash)
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 !isValidUsername(req.Username) {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "invalid username: only alphanumeric, underscore, dash, and dot allowed"})
return
}
if len(req.Password) < 12 {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 12)"})
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
}
hash, err := hashPassword(req.Password)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password hashing failed"})
return
}
err = userDB.Add(AppUser{
Username: req.Username, PassHash: hash,
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) < 12 {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 12)"})
return
}
hash, err := hashPassword(req.Password)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password hashing failed"})
return
}
if err := userDB.ChangePassword(username, hash); 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
}
apiURL := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s",
headscaleURL,
url.QueryEscape(req.User),
url.QueryEscape(req.Key))
hReq, _ := http.NewRequest(http.MethodPost, apiURL, 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 !isValidUsername(req.Name) {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "invalid username: only alphanumeric, underscore, dash, and dot allowed"})
return
}
if req.Password != "" && len(req.Password) < 12 {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 12)"})
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 != "" {
hash, err := hashPassword(req.Password)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password hashing failed"})
return
}
userDB.Add(AppUser{
Username: req.Name,
PassHash: hash,
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) < 12 {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password too short (min 12)"})
return
}
user := userDB.Find(name)
if user == nil {
jsonResp(w, http.StatusNotFound, map[string]string{"error": "login account not found for this user"})
return
}
hash, err := hashPassword(req.Password)
if err != nil {
jsonResp(w, http.StatusBadRequest, map[string]string{"error": "password hashing failed"})
return
}
if err := userDB.ChangePassword(name, hash); 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=" + url.QueryEscape(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
}
apiURL := fmt.Sprintf("%s/api/v1/node/register?user=%s&key=%s",
headscaleURL,
url.QueryEscape(username),
url.QueryEscape(req.Key))
hReq, _ := http.NewRequest(http.MethodPost, apiURL, 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)
}