mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
206
internal/httpapi/handlers_users.go
Normal file
206
internal/httpapi/handlers_users.go
Normal file
@ -0,0 +1,206 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// handleMe returns the authenticated principal. The frontend uses this to gate
|
||||
// UI (hide Users nav + disable write controls for viewer/audit users).
|
||||
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
id := identityFrom(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": id.Type,
|
||||
"name": id.Name,
|
||||
"level": id.Level,
|
||||
"userId": id.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
// handleUsers is the collection endpoint: GET lists users, POST creates one.
|
||||
// Admin only. password_hash is never serialized (User.PasswordHash has json:"-").
|
||||
func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := h.Store.ListUsers()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u, err := h.Store.CreateUser(req.Username, req.Password, req.Role)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserByName operates on /users/{name}: PUT updates role/enabled/password,
|
||||
// DELETE removes the user. System (flag-synced) users are read-only; the last
|
||||
// enabled admin cannot be deleted or disabled.
|
||||
func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/users/")
|
||||
if name == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u, ok := h.Store.GetUser(name)
|
||||
if !ok {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Role defaults to the current value when omitted.
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = u.Role
|
||||
}
|
||||
enabled := u.Enabled
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
// Guard: never disable/demote the last admin.
|
||||
if u.Role == "admin" && (role != "admin" || !enabled) {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.Store.UpdateUser(u.ID, role, enabled, req.Password); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
updated, _ := h.Store.GetUserByID(u.ID)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
case http.MethodDelete:
|
||||
if u.System {
|
||||
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if u.Role == "admin" {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot delete the last admin", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.Store.DeleteUser(u.ID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API keys ----
|
||||
|
||||
// handleApiKeys is the collection endpoint: GET lists keys (no hashes), POST
|
||||
// creates a key and returns the plaintext exactly once.
|
||||
func (h *Handler) handleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
keys, err := h.Store.ListApiKeys()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"apiKeys": keys})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Label string `json:"label"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
k, plaintext, err := h.Store.CreateApiKey(req.UserID, req.Label, req.Scope)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": k.ID,
|
||||
"userId": k.UserID,
|
||||
"label": k.Label,
|
||||
"scope": k.Scope,
|
||||
"prefix": k.Prefix,
|
||||
"createdAt": k.CreatedAt,
|
||||
"key": plaintext, // shown exactly once
|
||||
})
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// handleApiKeyByID operates on /apikeys/{id}: DELETE revokes the key.
|
||||
func (h *Handler) handleApiKeyByID(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/apikeys/")
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid key id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := h.Store.DeleteApiKey(id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// writeStoreErr maps store sentinel errors to HTTP statuses.
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
switch err {
|
||||
case store.ErrNotFound:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
case store.ErrAlreadyExists:
|
||||
http.Error(w, "already exists", http.StatusConflict)
|
||||
case store.ErrInvalid:
|
||||
http.Error(w, "invalid argument", http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user