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
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*
1031 lines
29 KiB
Go
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)
|
|
}
|