mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +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:
@ -30,15 +30,24 @@ type Node struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
Cache []string `json:"cache,omitempty"`
|
||||
LastSeen int64 `json:"lastSeen,omitempty"`
|
||||
// NodeKey is this member's cluster admission key. It travels in the
|
||||
// token so every node knows every peer's key — a crashed node can
|
||||
// rejoin via ANY cached peer by presenting that peer's key. Without
|
||||
// this, a rejoiner would only know its original sponsor's key (from
|
||||
// the -join-key flag) and couldn't rejoin through a different peer.
|
||||
NodeKey string `json:"nodeKey,omitempty"`
|
||||
}
|
||||
|
||||
// Load is the combined load metric used to pick the task claimer.
|
||||
type Load struct {
|
||||
MemPct float64 `json:"memPct"`
|
||||
NetPct float64 `json:"netPct"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
NetPct float64 `json:"netPct"`
|
||||
Forwards int `json:"forwards,omitempty"` // active forwards owned by this node (primary signal)
|
||||
}
|
||||
|
||||
func (l Load) Score() float64 { return l.MemPct + l.NetPct }
|
||||
// Score weights owned forwards heavily so the node with the fewest active
|
||||
// forwards is picked first; mem/net only break ties at equal forward count.
|
||||
func (l Load) Score() float64 { return float64(l.Forwards)*100 + l.MemPct + l.NetPct }
|
||||
|
||||
// Task is a PENDING forward request circulated in the token. It carries the
|
||||
// intermediate forwarding intent (local/remote/link) — NOT a rendered frpc
|
||||
@ -84,7 +93,6 @@ type State struct {
|
||||
PendingTasks map[string]*Task `json:"pendingTasks,omitempty"`
|
||||
// Topology: active forwards owned by members (full cluster view).
|
||||
Topology map[string]*TopoEntry `json:"topology,omitempty"`
|
||||
Seq int64 `json:"seq"`
|
||||
}
|
||||
|
||||
// Token is the circulating message: one physical token per cycle (single
|
||||
@ -207,8 +215,42 @@ func (s *State) LowestAlive() *Node {
|
||||
}
|
||||
|
||||
func (s *State) NextTaskID() string {
|
||||
s.Seq++
|
||||
return fmt.Sprintf("t%d", s.Seq)
|
||||
// Collision-free id allocation by scanning the ids actually in flight
|
||||
// (pending + topology) and taking max+1. The ring is mutually exclusive
|
||||
// (one token holder at a time), so when a node mints an id it has the
|
||||
// authoritative full view — scanning existing ids guarantees a fresh id.
|
||||
// n is tiny (handful of forwards). No Seq counter is needed: a cross-node
|
||||
// Seq was previously adopted wholesale on every OnToken (e.state = tk.State),
|
||||
// which dropped local increments and could regress below an id still in use,
|
||||
// recycling it and overwriting an active topology entry.
|
||||
max := int64(0)
|
||||
for id := range s.PendingTasks {
|
||||
if n := taskIDNum(id); n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
for _, e := range s.Topology {
|
||||
if n := taskIDNum(e.TaskID); n > max {
|
||||
max = n
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("t%d", max+1)
|
||||
}
|
||||
|
||||
// taskIDNum extracts the numeric suffix of a task id "t12" -> 12 (0 if it does
|
||||
// not parse). Used only to keep NextTaskID collision-free.
|
||||
func taskIDNum(id string) int64 {
|
||||
if len(id) < 2 || id[0] != 't' {
|
||||
return 0
|
||||
}
|
||||
var n int64
|
||||
for _, c := range id[1:] {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AddRemoveNode publishes a node-removal command via the token; the target
|
||||
|
||||
@ -4,6 +4,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@ -30,6 +31,20 @@ type Engine struct {
|
||||
Cache []string
|
||||
Handler Handler
|
||||
|
||||
// nodeKey is this node's cluster admission key. A newcomer must present
|
||||
// the sponsor's nodeKey (as JoinInfo.JoinKey) to join via it. Persisted
|
||||
// in store.Settings; stable across restarts so -join-key stays valid.
|
||||
// Generated lazily: on CreateCluster (creator) or AdoptState (joiner),
|
||||
// NOT at startup — a fresh node that hasn't created/joined has no key.
|
||||
// Cleared on detachAsStandalone (leaving the cluster invalidates the key).
|
||||
nodeKey string
|
||||
keyPersist func(key string) error // persists nodeKey to store (nil in tests)
|
||||
// peerPersist saves the cached peer list (JSON of [{addr,key},...]) so a
|
||||
// crashed node can auto-rejoin on restart via any cached peer. Called on
|
||||
// every token cycle (OnToken) and on AdoptState. Cleared (pass "") on
|
||||
// detachAsStandalone — an explicit leave must NOT auto-rejoin.
|
||||
peerPersist func(peersJSON string) error
|
||||
|
||||
state State
|
||||
// myAddr maps our Node ID to the address peers dial.
|
||||
myAddr string
|
||||
@ -81,10 +96,11 @@ type Engine struct {
|
||||
|
||||
// NewEngine builds the engine; state holds this node as initial leader unless
|
||||
// a peer list says otherwise (creation node starts the ring).
|
||||
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool) *Engine {
|
||||
func NewEngine(id, addr, user, pass, version string, cache []string, h Handler, send func(ctx context.Context, next string, tk *Token) error, selfAddr string, isLeader bool, nodeKey string) *Engine {
|
||||
e := &Engine{
|
||||
ID: id, Addr: addr, User: user, Pass: pass,
|
||||
Version: version, Cache: cache, Handler: h,
|
||||
nodeKey: nodeKey,
|
||||
state: State{
|
||||
LeaderID: "",
|
||||
Cycle: 0,
|
||||
@ -99,7 +115,7 @@ func NewEngine(id, addr, user, pass, version string, cache []string, h Handler,
|
||||
published: map[string]struct{}{},
|
||||
}
|
||||
n := Node{ID: id, Addr: selfAddr, Alive: true, IsLeader: isLeader,
|
||||
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache}
|
||||
Load: Load{MemPct: 10, NetPct: 10}, Version: version, Cache: cache, NodeKey: nodeKey}
|
||||
e.state.UpsertNode(n)
|
||||
return e
|
||||
}
|
||||
@ -121,12 +137,21 @@ func (e *Engine) myNode() Node {
|
||||
return e.state.Nodes[i]
|
||||
}
|
||||
|
||||
// loadSnapshot reads our runtime load (mem+net) from the handler.
|
||||
// loadSnapshot reads our runtime load. The primary signal is the count of
|
||||
// forwards this node currently owns (Forwards) — this is what makes the
|
||||
// lowest-load claim actually distribute tasks across nodes instead of the
|
||||
// leader hogging every claimable task in a single token pass (its stored
|
||||
// load was never refreshed between claims, so it stayed "lowest"). mem/net
|
||||
// from the handler only break ties at equal forward count.
|
||||
func (e *Engine) loadSnapshot() Load {
|
||||
l := Load{Forwards: len(e.state.ForwardsOwnedBy(e.ID))}
|
||||
if e.Handler != nil {
|
||||
return e.Handler.RuntimeLoad()
|
||||
b := e.Handler.RuntimeLoad()
|
||||
l.MemPct, l.NetPct = b.MemPct, b.NetPct
|
||||
} else {
|
||||
l.MemPct, l.NetPct = 20, 20
|
||||
}
|
||||
return Load{MemPct: 20, NetPct: 20}
|
||||
return l
|
||||
}
|
||||
|
||||
// OnToken is the SINGLE-ROUND token handler. Per the authoritative design
|
||||
@ -153,7 +178,15 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
||||
log.Printf("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
|
||||
|
||||
// Parallel rhythm timer: operations run while the pace clock ticks.
|
||||
rhythm := time.NewTimer(ringHopDelay)
|
||||
// Delay scales with alive node count (more nodes → lower per-hop delay,
|
||||
// keeping the round time ~constant for real-time sync).
|
||||
alive := 0
|
||||
for i := range tk.State.Nodes {
|
||||
if tk.State.Nodes[i].Alive {
|
||||
alive++
|
||||
}
|
||||
}
|
||||
rhythm := time.NewTimer(hopDelayFor(alive))
|
||||
defer rhythm.Stop()
|
||||
|
||||
// (a) ADOPT the cluster picture. Incoming state is authoritative: joins
|
||||
@ -219,7 +252,7 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
||||
ID: e.ID, Addr: e.myAddr, Alive: true,
|
||||
IsLeader: e.state.LeaderID == e.ID,
|
||||
Load: e.loadSnapshot(),
|
||||
Version: e.Version, Cache: e.Cache,
|
||||
Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
|
||||
})
|
||||
}
|
||||
|
||||
@ -243,6 +276,8 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
||||
// Publish our consolidated state back into the token.
|
||||
tk.State = e.state
|
||||
e.lastSyncAt = time.Now().Unix()
|
||||
// Persist the cached peer list so a crash/restart can auto-rejoin.
|
||||
e.persistPeers()
|
||||
// Record the tasks leaving on this token so their absence from the next
|
||||
// incoming token is recognized as "consumed downstream" rather than
|
||||
// "never sent" — otherwise the localPending re-merge above would
|
||||
@ -352,16 +387,33 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
wasLeader := e.state.LeaderID == e.ID
|
||||
if succ, ok := e.state.AliveSuccessor(e.ID); ok && succ != e.ID {
|
||||
e.removedNext = succ
|
||||
}
|
||||
e.state.SelfRemove(e.ID)
|
||||
e.selfRemoved = true
|
||||
// Leader failover: if we were the leader, the ring would run
|
||||
// leaderless after our departure — LeaderID would be "" (SelfRemove
|
||||
// clears it), no node would call Send (cycle never advances), and
|
||||
// WatchLeader can't find AlivePredecessor("") to promote a
|
||||
// successor. Designate the captured successor as the new leader
|
||||
// so the token carries a valid LeaderID downstream; the successor
|
||||
// then calls Send on its turn and the ring keeps cycling.
|
||||
if wasLeader && e.removedNext != "" {
|
||||
e.state.LeaderID = e.removedNext
|
||||
for i := range e.state.Nodes {
|
||||
e.state.Nodes[i].IsLeader = e.state.Nodes[i].ID == e.removedNext
|
||||
}
|
||||
if e.Log != nil {
|
||||
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.removedNext})
|
||||
}
|
||||
}
|
||||
if e.Log != nil {
|
||||
_, _ = e.Log.Append(e.ID, LogNodeLeave, map[string]string{"node": e.ID})
|
||||
}
|
||||
log.Printf("ring[%s] self-removed from cluster (token command %s); next=%s",
|
||||
e.ID, claimed.ID, e.removedNext)
|
||||
log.Printf("ring[%s] self-removed from cluster (token command %s); next=%s leader=%s",
|
||||
e.ID, claimed.ID, e.removedNext, e.state.LeaderID)
|
||||
continue
|
||||
}
|
||||
if claimed.RemoveNode != "" {
|
||||
@ -376,6 +428,20 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
e.ID, claimed.ID, claimed.RemoveNode)
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth against duplicate claims: if an active topology
|
||||
// entry for this forward already exists (owned by us or another
|
||||
// node), this task is a stale resurrected copy or a multi-token
|
||||
// collision — drop it WITHOUT spawning, so we never end up with an
|
||||
// orphaned worker running a forward the topology attributes to a
|
||||
// different node. Safe for OfflineReassign: that path deletes the
|
||||
// topology entry BEFORE re-queueing, so TopologyOwner returns "" and
|
||||
// the legitimate re-claim passes through.
|
||||
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
||||
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
|
||||
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
|
||||
claimed.Link.RemotePort, owner)
|
||||
continue
|
||||
}
|
||||
if e.Handler != nil {
|
||||
if err := e.Handler.Claim(ctx, claimed); err != nil {
|
||||
e.state.PendingTasks[claimed.ID] = claimed
|
||||
@ -389,6 +455,13 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
})
|
||||
}
|
||||
e.state.AddTopology(claimed, e.ID)
|
||||
// Refresh our own stored load so the next selfIsLowest check in this
|
||||
// same pass sees the incremented Forwards count — otherwise we'd keep
|
||||
// claiming (stored load stays stale until we forward the token) and
|
||||
// hog every claimable task, defeating lowest-load distribution.
|
||||
if i := e.state.Find(e.ID); i >= 0 {
|
||||
e.state.Nodes[i].Load = e.loadSnapshot()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -397,29 +470,72 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
// It is the transport hook used by the HTTP handler after OnToken. If this
|
||||
// node just self-removed, its ID is no longer in state.Nodes so
|
||||
// AliveSuccessor would fail — use the original successor captured before
|
||||
// removal (plan §移除节点 step 3: "令牌传递给自身原本的下一家").
|
||||
// removal (plan §移除节点 step 3: "令牌传递给自身原本的下一家"). Otherwise
|
||||
// delegates to forwardToNext which handles send-failure → mark offline →
|
||||
// reassign → try next hop (plan §故障自幽).
|
||||
func (e *Engine) Forward(ctx context.Context, tk *Token) error {
|
||||
if e.removedNext != "" {
|
||||
next := e.removedNext
|
||||
e.removedNext = ""
|
||||
var err error
|
||||
if e.send != nil {
|
||||
log.Printf("ring[%s] forward (self-removed) cycle=%d to %s", e.ID, tk.Cycle, next)
|
||||
return e.send(ctx, next, tk)
|
||||
err = e.send(ctx, next, tk)
|
||||
}
|
||||
return nil
|
||||
// After handing off the token to the old successor, detach to a
|
||||
// fresh standalone state so Snapshot() no longer serves the old
|
||||
// cluster picture (members, topology, log) after self-leave. The
|
||||
// token already carries the published state (with the new leader if
|
||||
// we designated one); the local reset does not affect the sent token.
|
||||
if e.selfRemoved {
|
||||
e.detachAsStandalone()
|
||||
}
|
||||
return err
|
||||
}
|
||||
next, ok := e.state.AliveSuccessor(e.ID)
|
||||
if !ok {
|
||||
return nil // single-node ring
|
||||
return e.forwardToNext(ctx, tk)
|
||||
}
|
||||
|
||||
// detachAsStandalone resets the engine to a fresh standalone state after a
|
||||
// self-leave has completed (the token was handed to the old successor). This
|
||||
// prevents Snapshot() from serving the old cluster picture — other members,
|
||||
// the full topology, pending tasks, and the cluster log — after the node has
|
||||
// permanently left the ring. Equivalent to CreateCluster minus the IsMember
|
||||
// guard (we are already detached) plus a fresh log (old cluster events stale).
|
||||
func (e *Engine) detachAsStandalone() {
|
||||
// Leaving the cluster invalidates this node's admission key — a
|
||||
// standalone node has no key until it creates/joins again. Clear both
|
||||
// the in-memory key and the persisted copy (so a restart doesn't
|
||||
// resurrect a stale key for a node that's no longer in any cluster).
|
||||
e.nodeKey = ""
|
||||
if e.keyPersist != nil {
|
||||
_ = e.keyPersist("")
|
||||
}
|
||||
if next == e.ID {
|
||||
return nil // never forward to ourselves
|
||||
// Clear the cached peer list so this node does NOT auto-rejoin on
|
||||
// restart — it explicitly left the cluster.
|
||||
e.clearPeers()
|
||||
e.state = State{
|
||||
LeaderID: e.ID,
|
||||
PendingTasks: map[string]*Task{},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
RoundDelay: 2 * time.Second,
|
||||
}
|
||||
if e.send != nil {
|
||||
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
|
||||
return e.send(ctx, next, tk)
|
||||
e.state.UpsertNode(Node{
|
||||
ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
|
||||
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
|
||||
})
|
||||
e.selfRemoved = false
|
||||
e.removedNext = ""
|
||||
e.lastRingStart = time.Time{}
|
||||
e.lastTokenAt = 0
|
||||
e.lastSyncAt = 0
|
||||
e.inflight.clear()
|
||||
e.failCount = map[string]int{}
|
||||
e.published = map[string]struct{}{}
|
||||
if e.Log != nil {
|
||||
e.Log = NewClusterLog()
|
||||
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
|
||||
}
|
||||
return nil
|
||||
log.Printf("ring[%s] detached to standalone after self-leave", e.ID)
|
||||
}
|
||||
|
||||
// Snapshot returns a serializable view of the ring for the frontend.
|
||||
@ -433,6 +549,9 @@ type RingSnapshot struct {
|
||||
Pending []*Task `json:"pending"`
|
||||
Topology []*TopoEntry `json:"topology"`
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
// NodeKey: this node's cluster admission key. The cluster page displays it
|
||||
// so the operator can copy it for newcomers joining via this node.
|
||||
NodeKey string `json:"nodeKey,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Engine) Snapshot() *RingSnapshot {
|
||||
@ -445,6 +564,7 @@ func (e *Engine) Snapshot() *RingSnapshot {
|
||||
Nodes: e.state.Nodes,
|
||||
Pending: e.state.PendingList(),
|
||||
Topology: e.state.TopologyList(),
|
||||
NodeKey: e.nodeKey,
|
||||
}
|
||||
if e.Log != nil {
|
||||
snap.Log = e.Log.Snapshot()
|
||||
@ -509,6 +629,10 @@ type JoinInfo struct {
|
||||
Addr string `json:"addr"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Cache []string `json:"cache,omitempty"`
|
||||
// JoinKey is the sponsor's nodeKey — the newcomer must present it to
|
||||
// prove it is authorized to join via the sponsor. The sponsor verifies
|
||||
// ji.JoinKey == e.nodeKey; mismatch → 403.
|
||||
JoinKey string `json:"joinKey,omitempty"`
|
||||
}
|
||||
|
||||
// injectPendingJoin writes every queued newcomer into state right after
|
||||
@ -572,10 +696,17 @@ func (e *Engine) AdoptState(s State) {
|
||||
e.state.PendingTasks[id] = t
|
||||
}
|
||||
e.state.UpsertNode(Node{ID: e.ID, Addr: e.myAddr, Alive: true,
|
||||
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache})
|
||||
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey})
|
||||
if e.Log != nil {
|
||||
e.Log.Append(e.ID, LogNodeJoin, map[string]string{"node": e.ID, "addr": e.myAddr})
|
||||
}
|
||||
// Newcomer generates its own admission key after joining, so future
|
||||
// nodes can join via it. Per the user's design: "每个节点加入集群后
|
||||
// 生成自身密钥". A node that already has a persisted key (restart
|
||||
// re-join) keeps it.
|
||||
e.ensureNodeKey()
|
||||
// Persist the cached peer list from the adopted ring state.
|
||||
e.persistPeers()
|
||||
}
|
||||
|
||||
// CreateCluster reseeds this node as a fresh standalone leader (single-node
|
||||
@ -588,17 +719,17 @@ func (e *Engine) CreateCluster() error {
|
||||
if e.IsMember() {
|
||||
return fmt.Errorf("node is a multi-node cluster member; leave first")
|
||||
}
|
||||
e.ensureNodeKey()
|
||||
e.state = State{
|
||||
LeaderID: e.ID,
|
||||
Cycle: 0,
|
||||
PendingTasks: map[string]*Task{},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
RoundDelay: 2 * time.Second,
|
||||
Seq: e.state.Seq,
|
||||
}
|
||||
e.state.UpsertNode(Node{
|
||||
ID: e.ID, Addr: e.myAddr, Alive: true, IsLeader: true,
|
||||
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache,
|
||||
Load: e.loadSnapshot(), Version: e.Version, Cache: e.Cache, NodeKey: e.nodeKey,
|
||||
})
|
||||
e.selfRemoved = false
|
||||
e.removedNext = ""
|
||||
@ -652,6 +783,80 @@ func (e *Engine) HasTask(local, remote string, port int) bool {
|
||||
// IsLeader reports whether this node is the current ring leader.
|
||||
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
|
||||
|
||||
// NodeKey returns this node's cluster admission key (for the frontend to
|
||||
// display so the operator can copy it for newcomers).
|
||||
func (e *Engine) NodeKey() string { return e.nodeKey }
|
||||
|
||||
// SetKeyPersist installs the callback used to persist the nodeKey to durable
|
||||
// storage (store.SetNodeKey). Called once from main.go after NewEngine. Tests
|
||||
// leave it nil — ensureNodeKey still generates the key in-memory.
|
||||
func (e *Engine) SetKeyPersist(fn func(key string) error) { e.keyPersist = fn }
|
||||
|
||||
// SetPeerPersist installs the callback used to persist the cached peer list
|
||||
// to durable storage (store.SetClusterPeers). Called once from main.go.
|
||||
func (e *Engine) SetPeerPersist(fn func(peersJSON string) error) { e.peerPersist = fn }
|
||||
|
||||
// persistPeers extracts all alive peers (addr + nodeKey, excluding self)
|
||||
// from the current ring state and persists them via the peerPersist callback.
|
||||
// Called on every token cycle (OnToken) and on AdoptState so a crashed node
|
||||
// always has the latest peer list to rejoin through. Skipped for standalone
|
||||
// (single-node) rings — a standalone node has no peers to cache.
|
||||
func (e *Engine) persistPeers() {
|
||||
if e.peerPersist == nil {
|
||||
return
|
||||
}
|
||||
type peerEntry struct {
|
||||
Addr string `json:"addr"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
var peers []peerEntry
|
||||
for _, n := range e.state.Nodes {
|
||||
if n.ID == e.ID || !n.Alive {
|
||||
continue
|
||||
}
|
||||
if n.Addr == "" || n.NodeKey == "" {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, peerEntry{Addr: n.Addr, Key: n.NodeKey})
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return // standalone or all-offline: don't overwrite a good cache
|
||||
}
|
||||
data, err := json.Marshal(peers)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := e.peerPersist(string(data)); err != nil {
|
||||
log.Printf("ring[%s] persist cluster peers failed: %v", e.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearPeers wipes the cached peer list (called from detachAsStandalone so
|
||||
// an explicit leave does NOT auto-rejoin on restart).
|
||||
func (e *Engine) clearPeers() {
|
||||
if e.peerPersist != nil {
|
||||
_ = e.peerPersist("")
|
||||
}
|
||||
}
|
||||
|
||||
// ensureNodeKey generates a random admission key if this node doesn't have one
|
||||
// yet, and persists it via the keyPersist callback (so it survives restarts).
|
||||
// Called from CreateCluster (the creator generates a key so others can join
|
||||
// via it) and AdoptState (a newcomer generates its own key after joining, so
|
||||
// future nodes can join via it). Per the user's design: "每个节点加入集群后
|
||||
// 生成自身密钥" — the key is born with cluster membership, not at startup.
|
||||
func (e *Engine) ensureNodeKey() {
|
||||
if e.nodeKey != "" {
|
||||
return
|
||||
}
|
||||
e.nodeKey = GenerateNodeKey()
|
||||
if e.keyPersist != nil {
|
||||
if err := e.keyPersist(e.nodeKey); err != nil {
|
||||
log.Printf("ring[%s] persist nodeKey failed: %v", e.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsMember reports whether this node is currently an active multi-node member
|
||||
// (self is in the ring alongside others). Used by the create/join gates to
|
||||
// refuse actions that would split an active ring. A detached node (self not
|
||||
|
||||
@ -3,6 +3,8 @@ package cluster
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
type fakeHandler struct {
|
||||
@ -33,7 +35,7 @@ func sendNull(ctx context.Context, next string, tk *Token) error { return nil }
|
||||
// task gets claimed (lowest load = only node).
|
||||
func TestSingleNodeCycle(t *testing.T) {
|
||||
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", []string{"0.71.0"},
|
||||
&fakeHandler{load: Load{MemPct: 30, NetPct: 30}}, sendNull, "n1:7500", true)
|
||||
&fakeHandler{load: Load{MemPct: 30, NetPct: 30}}, sendNull, "n1:7500", true, "")
|
||||
eng.StartRing(context.Background())
|
||||
|
||||
// single node: token stays local; no successor so nothing travels.
|
||||
@ -46,7 +48,7 @@ func TestSingleNodeCycle(t *testing.T) {
|
||||
// stamps; then phase flips and second round syncs.
|
||||
func TestTwoNodesSingleRound(t *testing.T) {
|
||||
eng2 := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
|
||||
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false)
|
||||
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendNull, "n2:7500", false, "")
|
||||
eng2.state.UpsertNode(Node{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{MemPct: 50, NetPct: 50}})
|
||||
|
||||
// n1 sends a token carrying the cluster picture; single-round processing:
|
||||
@ -69,3 +71,119 @@ func TestTwoNodesSingleRound(t *testing.T) {
|
||||
t.Fatal("nil token after processing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaderRoundTripNoResurrection: when the leader submits a task and the
|
||||
// token completes a full round (leader→n2 claims→n3→leader), the consumed task
|
||||
// MUST NOT be resurrected on the leader's next OnToken, and the topology must
|
||||
// still attribute the forward to the single claimer (n2).
|
||||
//
|
||||
// This holds NOT via a published-set mark on StartRing, but via Go map
|
||||
// reference semantics: StartRing sets tk.State = e.state, so the token's
|
||||
// PendingTasks map IS the leader's own map. When n2 calls ClaimPending it
|
||||
// deletes from that shared map — the deletion is visible to the leader too.
|
||||
// By the time the token returns, the claimed task is already gone from the
|
||||
// leader's e.state.PendingTasks, so the localPending re-merge has nothing to
|
||||
// re-inject. Single token + remove-on-claim = single claim (plan §M6).
|
||||
func TestLeaderRoundTripNoResurrection(t *testing.T) {
|
||||
// 3-node ring: n1 (leader+submitter), n2 (lowest, claims), n3 (idle).
|
||||
var sent *Token
|
||||
sendCap := func(ctx context.Context, next string, tk *Token) error {
|
||||
sent = tk
|
||||
return nil
|
||||
}
|
||||
n1 := NewEngine("n1", "n1:7500", "u", "p", "v", nil,
|
||||
&fakeHandler{load: Load{MemPct: 50, NetPct: 50}}, sendCap, "n1:7500", true, "")
|
||||
// n2/n3 carry a lower stored load than n1's NewEngine default ({10,10})
|
||||
// so LowestAlive picks n2 (first among the tied low nodes) as the claimer.
|
||||
n1.state.UpsertNode(Node{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 1, NetPct: 1}})
|
||||
n1.state.UpsertNode(Node{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{MemPct: 1, NetPct: 1}})
|
||||
|
||||
task := n1.SubmitTask(store.Local{Name: "l1"}, store.Remote{Name: "r1"}, store.Link{RemotePort: 100})
|
||||
if task == nil {
|
||||
t.Fatal("submit returned nil")
|
||||
}
|
||||
n1.StartRing(context.Background())
|
||||
if sent == nil {
|
||||
t.Fatal("StartRing did not send a token")
|
||||
}
|
||||
|
||||
// n2 receives, claims t1 (lowest load), establishes topology.
|
||||
n2 := NewEngine("n2", "n2:7500", "u", "p", "v", nil,
|
||||
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendCap, "n2:7500", false, "")
|
||||
out2, err := n2.OnToken(context.Background(), sent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(n2.state.TopologyList()); got != 1 {
|
||||
t.Fatalf("n2 should own 1 forward, topo=%+v", n2.state.TopologyList())
|
||||
}
|
||||
if len(n2.state.PendingList()) != 0 {
|
||||
t.Fatalf("pending should be empty after n2 claim, got %+v", n2.state.PendingList())
|
||||
}
|
||||
|
||||
// n3 receives, nothing to claim, forwards.
|
||||
n3 := NewEngine("n3", "n3:7500", "u", "p", "v", nil,
|
||||
&fakeHandler{load: Load{MemPct: 10, NetPct: 10}}, sendCap, "n3:7500", false, "")
|
||||
out3, err := n3.OnToken(context.Background(), out2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Token returns to n1. The consumed task t1 MUST NOT be resurrected.
|
||||
if _, err := n1.OnToken(context.Background(), out3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(n1.state.PendingList()); got != 0 {
|
||||
t.Fatalf("n1 resurrected a consumed task: pending=%+v (single-token + shared-map must prevent this)",
|
||||
n1.state.PendingList())
|
||||
}
|
||||
// Topology still attributes t1 to n2 (not overwritten by a re-claim).
|
||||
topo := n1.state.TopologyList()
|
||||
if len(topo) != 1 || topo[0].OwnerID != "n2" {
|
||||
t.Fatalf("topology should be t1@n2, got %+v", topo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimGuardDropsDuplicateForward: when a node (lowest load) receives a
|
||||
// pending task whose forward is ALREADY in the topology owned by another node
|
||||
// (a resurrected/stale copy), the claim path must drop it WITHOUT spawning a
|
||||
// worker or overwriting the topology. Without the guard a second worker for
|
||||
// the same forward would be spawned and orphaned (the topology entry is keyed
|
||||
// by task id, so AddTopology would silently overwrite the owner).
|
||||
func TestClaimGuardDropsDuplicateForward(t *testing.T) {
|
||||
// n1 is lowest load and would otherwise claim; the fake Claim hook fails
|
||||
// the test if ever called.
|
||||
n1 := NewEngine("n1", "n1:7500", "u", "p", "v", nil,
|
||||
&fakeHandler{
|
||||
load: Load{MemPct: 10, NetPct: 10},
|
||||
claim: func(ctx context.Context, tk *Task) error {
|
||||
t.Fatalf("Claim must not be called for an already-owned forward: %s", tk.ID)
|
||||
return nil
|
||||
},
|
||||
}, sendNull, "n1:7500", true, "")
|
||||
|
||||
dup := &Task{ID: "t1", Local: store.Local{Name: "l1"}, Remote: store.Remote{Name: "r1"}, Link: store.Link{RemotePort: 100}}
|
||||
tk := &Token{Cycle: 1, State: State{
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Addr: "n1:7500", Alive: true, Load: Load{MemPct: 10, NetPct: 10}},
|
||||
{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 50, NetPct: 50}},
|
||||
},
|
||||
PendingTasks: map[string]*Task{"t1": dup},
|
||||
Topology: map[string]*TopoEntry{"t1": {
|
||||
TaskID: "t1", OwnerID: "n2", Local: store.Local{Name: "l1"},
|
||||
Remote: store.Remote{Name: "r1"}, Link: store.Link{RemotePort: 100}, Active: true,
|
||||
}},
|
||||
}}
|
||||
if _, err := n1.OnToken(context.Background(), tk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pending drained (the stale task was consumed/dropped, not left to ride).
|
||||
if got := len(n1.state.PendingList()); got != 0 {
|
||||
t.Fatalf("stale task should be dropped, pending=%+v", n1.state.PendingList())
|
||||
}
|
||||
// Topology untouched: still owned by n2.
|
||||
topo := n1.state.TopologyList()
|
||||
if len(topo) != 1 || topo[0].OwnerID != "n2" {
|
||||
t.Fatalf("topology should remain t1@n2, got %+v", topo)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ringHopDelay paces each token hop (real-world cadence, avoids busy-loop).
|
||||
// 500ms keeps a 3-node ring well under the LossTimeout floor (2500ms) so a
|
||||
// healthy round is never misjudged lost, while not idly burning CPU/HTTP at
|
||||
// 20Hz like the old 50ms did.
|
||||
const ringHopDelay = 500 * time.Millisecond
|
||||
// Per-hop pacing bounds. The hop delay scales DOWN as the ring grows so the
|
||||
// round time stays ~ringHopDelayMax regardless of node count — a static
|
||||
// 500ms made large clusters slow (3 nodes=1.5s, 5 nodes=2.5s, 10 nodes=5s
|
||||
// per round); now 3/5/10 nodes all round at ~500ms (until the floor bites),
|
||||
// keeping sync real-time without a token storm (round freq ≈2Hz).
|
||||
const (
|
||||
ringHopDelayMax = 500 * time.Millisecond
|
||||
ringHopDelayMin = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// hopDelayFor returns the per-hop pace for a ring of aliveNodes members.
|
||||
// Nodes越多延迟越低: delay = ringHopDelayMax / aliveNodes, floored at min.
|
||||
// n=2→250ms, n=3→167ms, n=5→100ms, n=10→50ms(floor) — round time ≈500ms.
|
||||
func hopDelayFor(aliveNodes int) time.Duration {
|
||||
if aliveNodes < 2 {
|
||||
aliveNodes = 2
|
||||
}
|
||||
d := ringHopDelayMax / time.Duration(aliveNodes)
|
||||
if d < ringHopDelayMin {
|
||||
d = ringHopDelayMin
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// LossTimeout is the token-loss threshold per design: roundDelay/2 + 20ms,
|
||||
// floored so a healthy fast ring is never misjudged.
|
||||
@ -92,8 +110,14 @@ func (e *Engine) Send(ctx context.Context, tk *Token) error {
|
||||
return e.forwardToNext(ctx, tk)
|
||||
}
|
||||
|
||||
// forwardToNext sends the token to the next alive successor; on failure it
|
||||
// marks that node offline, reattaches its tasks, and tries the next hop.
|
||||
// forwardToNext sends the token to the next alive successor. On send
|
||||
// failure (no receipt within the HTTP timeout = neighbor unreachable),
|
||||
// the normal node-death procedure fires: mark offline, reassign the dead
|
||||
// node's tasks, and try the next hop. If the dead node was the leader,
|
||||
// the predecessor reuses the same procedure and additionally promotes
|
||||
// itself to leader + starts a fresh cycle (the token destined for the
|
||||
// dead leader is lost; a new cycle must begin). This is the PRIMARY
|
||||
// leader-death detection path per plan §故障自幽 + §leader 补充/监控.
|
||||
func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
|
||||
for hops := 0; hops < len(e.state.Nodes); hops++ {
|
||||
next, ok := e.nextRecipient()
|
||||
@ -104,26 +128,32 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
|
||||
if e.send == nil {
|
||||
return nil
|
||||
}
|
||||
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
|
||||
err := e.send(ctx, next, tk)
|
||||
if err == nil {
|
||||
if e.state.LeaderID == e.ID {
|
||||
e.inflight.mark(e.state.RoundDelay)
|
||||
}
|
||||
// Pace the ring so tokens circulate at a realistic cadence.
|
||||
// Pacing handled by the parallel rhythm timer in OnToken.
|
||||
return nil
|
||||
}
|
||||
log.Printf("ring[%s] send to %s failed: %v", e.ID, next, err)
|
||||
// Do not declare a neighbor offline on a single timeout: transient
|
||||
// send failures (network jitter, busy handler) must not break the
|
||||
// ring. Only after consecutive failures do we evict the node.
|
||||
e.failMu.Lock()
|
||||
e.failCount[next]++
|
||||
if e.failCount[next] >= 2 {
|
||||
e.state.MarkOffline(next)
|
||||
e.state.OfflineReassign(next)
|
||||
delete(e.failCount, next)
|
||||
// Send failed = no receipt within timeout = neighbor offline.
|
||||
// Normal node-death: mark offline, reassign tasks to pending.
|
||||
log.Printf("ring[%s] send to %s failed (no receipt): %v", e.ID, next, err)
|
||||
e.state.MarkOffline(next)
|
||||
e.state.OfflineReassign(next)
|
||||
// If the dead node was the leader, promote self and start a new
|
||||
// cycle. The token was going to the leader; with the leader dead
|
||||
// the token is lost — start fresh as the new leader (plan §leader
|
||||
// 补充/监控: "上家邻居探测到 leader 崩溃 → 自身成为新 leader").
|
||||
if next == e.state.LeaderID {
|
||||
e.becomeLeader()
|
||||
if e.Log != nil {
|
||||
_, _ = e.Log.Append(e.ID, LogLeaderChange, map[string]string{"leader": e.ID})
|
||||
}
|
||||
e.StartRing(ctx)
|
||||
return nil
|
||||
}
|
||||
// Non-leader neighbor death: continue to the next recipient.
|
||||
}
|
||||
e.inflight.clear()
|
||||
return nil
|
||||
@ -138,10 +168,26 @@ func (e *Engine) becomeLeader() {
|
||||
log.Printf("ring[%s] promoted to leader", e.ID)
|
||||
}
|
||||
|
||||
// WatchLeader runs the leader liveness monitor: the predecessor of the leader
|
||||
// pings it; on failure it marks the leader offline and promotes itself.
|
||||
// WatchLeader runs the FALLBACK leader liveness monitor. The PRIMARY path is
|
||||
// forwardToNext: when the predecessor sends a token to the leader and the send
|
||||
// fails (leader's HTTP server down), forwardToNext marks the leader offline and
|
||||
// promotes self. WatchLeader covers the case forwardToNext CANNOT detect:
|
||||
// the leader received the token (POST returned 200) but then crashed/restarted/
|
||||
// detached before forwarding it — the send succeeded, so forwardToNext sees no
|
||||
// error. In this case the predecessor pings the leader; if the leader is down
|
||||
// (connection refused) or restarted/detached (standalone → 409), the heartbeat
|
||||
// fails and the predecessor takes over.
|
||||
//
|
||||
// The predecessor role is NOT permanent — it shifts as the ring topology
|
||||
// changes (nodes join/leave). Each tick re-evaluates AlivePredecessor(LeaderID)
|
||||
// so the correct node monitors the leader at all times. Per design:
|
||||
// "上邻居也不是永久的,也要有普通节点按照令牌传递的拓扑变换转换为上邻居的逻辑".
|
||||
//
|
||||
// Interval = 1s so worst-case detection (tick + 1.5s ping timeout ≈ 2.5s)
|
||||
// aligns with LossTimeout (roundDelay/2 + 20ms, floored at 2500ms), per design:
|
||||
// "与leader超时重发时间一致".
|
||||
func (e *Engine) WatchLeader(ctx context.Context) {
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
@ -164,6 +210,11 @@ func (e *Engine) WatchLeader(ctx context.Context) {
|
||||
e.state.MarkOffline(e.state.LeaderID)
|
||||
e.state.OfflineReassign(e.state.LeaderID)
|
||||
e.becomeLeader()
|
||||
// Kick off a fresh cycle: the ring died with the old
|
||||
// leader (no token inflight → WatchTokenLoss won't
|
||||
// fire). Without this the newly promoted leader would
|
||||
// sit idle and the ring would stay dead.
|
||||
e.StartRing(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ func newTestEngine(id string, isLeader bool) *Engine {
|
||||
return NewEngine(id, id+":7500", "u", "p", "0.1.0", nil,
|
||||
&fakeHandler{load: Load{MemPct: 20, NetPct: 20}},
|
||||
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||
id+":7500", isLeader)
|
||||
id+":7500", isLeader, "")
|
||||
}
|
||||
|
||||
// TestLogAppendDeltaReplay: append entries, extract delta after a watermark,
|
||||
|
||||
@ -104,7 +104,7 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
|
||||
eng := NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
|
||||
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||
claim: func(ctx context.Context, tk *Task) error { claimed = append(claimed, tk); return nil }},
|
||||
sendNull, "n1:7500", true)
|
||||
sendNull, "n1:7500", true, "")
|
||||
rm := eng.state.AddRemoveNode("node-x:7500") // target not in the ring
|
||||
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -119,3 +119,102 @@ func TestRemoveCommandFulfilledNoPhantom(t *testing.T) {
|
||||
t.Fatalf("phantom topology entry created: %+v", eng.state.TopologyList())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaderSelfRemoveDesignatesSuccessor: when the LEADER self-removes, it
|
||||
// must designate its successor as the new leader before publishing the token.
|
||||
// Without this, LeaderID would be "" (SelfRemove clears it), no node would
|
||||
// call Send (cycle never advances), and WatchLeader can't find
|
||||
// AlivePredecessor("") to promote anyone — the ring runs leaderless and dies.
|
||||
func TestLeaderSelfRemoveDesignatesSuccessor(t *testing.T) {
|
||||
eng := newTestEngine("n1", true)
|
||||
eng.state.InsertAfter("n1", Node{
|
||||
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
|
||||
})
|
||||
// n1 (leader) publishes a remove-node command for itself.
|
||||
rm := eng.state.AddRemoveNode("n1")
|
||||
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
|
||||
|
||||
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("nil token after OnToken")
|
||||
}
|
||||
// n1 removed itself from the ring.
|
||||
if eng.state.Find("n1") >= 0 {
|
||||
t.Fatalf("n1 still in ring: %+v", eng.state.Nodes)
|
||||
}
|
||||
// n2 designated as the new leader (not "" — the old bug).
|
||||
if eng.state.LeaderID != "n2" {
|
||||
t.Fatalf("LeaderID = %q want n2 (successor should be designated as leader)", eng.state.LeaderID)
|
||||
}
|
||||
// n2 marked IsLeader in Nodes.
|
||||
if i := eng.state.Find("n2"); i >= 0 && !eng.state.Nodes[i].IsLeader {
|
||||
t.Fatalf("n2 not marked IsLeader: %+v", eng.state.Nodes[i])
|
||||
}
|
||||
// removedNext captured so Forward can hand the token to the old successor.
|
||||
if eng.removedNext != "n2" {
|
||||
t.Fatalf("removedNext = %q want n2", eng.removedNext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetachAfterForward: after self-leave + Forward (token handed to the old
|
||||
// successor), the engine resets to a fresh standalone state so Snapshot() no
|
||||
// longer serves the old cluster picture (members, topology, pending, log).
|
||||
func TestDetachAfterForward(t *testing.T) {
|
||||
eng := newTestEngine("n1", true)
|
||||
eng.state.InsertAfter("n1", Node{
|
||||
ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 90, NetPct: 90},
|
||||
})
|
||||
// Give n1 an owned forward so the old state has a non-empty topology.
|
||||
p := eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||
eng.state.ClaimPending(p.ID)
|
||||
eng.state.AddTopology(p, "n1")
|
||||
|
||||
rm := eng.state.AddRemoveNode("n1")
|
||||
eng.state.PendingTasks = map[string]*Task{rm.ID: rm}
|
||||
|
||||
out, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("nil token after OnToken")
|
||||
}
|
||||
// Before Forward: self-removed, removedNext captured, old state retained
|
||||
// (n2 still in Nodes, n1's forward re-queued as pending by SelfRemove).
|
||||
if !eng.selfRemoved {
|
||||
t.Fatal("selfRemoved not set before Forward")
|
||||
}
|
||||
if eng.removedNext != "n2" {
|
||||
t.Fatalf("removedNext = %q want n2", eng.removedNext)
|
||||
}
|
||||
if eng.state.Find("n2") < 0 {
|
||||
t.Fatal("n2 (old member) missing before Forward — test setup wrong")
|
||||
}
|
||||
|
||||
// Forward hands off the token (sendNull no-op) then detaches to standalone.
|
||||
if err := eng.Forward(context.Background(), out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After Forward: fresh standalone state.
|
||||
if eng.state.LeaderID != "n1" {
|
||||
t.Fatalf("LeaderID = %q want n1 (standalone leader after detach)", eng.state.LeaderID)
|
||||
}
|
||||
if len(eng.state.Nodes) != 1 || eng.state.Nodes[0].ID != "n1" {
|
||||
t.Fatalf("state not standalone (want just n1): %+v", eng.state.Nodes)
|
||||
}
|
||||
if len(eng.state.Topology) != 0 {
|
||||
t.Fatalf("topology not cleared after detach: %+v", eng.state.Topology)
|
||||
}
|
||||
if len(eng.state.PendingTasks) != 0 {
|
||||
t.Fatalf("pending not cleared after detach: %+v", eng.state.PendingList())
|
||||
}
|
||||
if eng.selfRemoved {
|
||||
t.Fatal("selfRemoved should be cleared after detach")
|
||||
}
|
||||
if eng.removedNext != "" {
|
||||
t.Fatalf("removedNext should be empty after detach, got %q", eng.removedNext)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
|
||||
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||
revoke: func(ctx context.Context, tk *Task) error { revoked = true; return nil }},
|
||||
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||
"n1:7500", true)
|
||||
"n1:7500", true, "")
|
||||
|
||||
// establish a forward
|
||||
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||
|
||||
@ -6,12 +6,23 @@ package cluster
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateNodeKey returns a random 16-byte hex string for use as a cluster
|
||||
// admission key. Called on first startup when no key is persisted yet.
|
||||
func GenerateNodeKey() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// JoinRing asks target to add us to its ring and returns the adopted state.
|
||||
func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) error {
|
||||
url := fmt.Sprintf("http://%s/api/manager/cluster/join", targetAddr)
|
||||
@ -49,9 +60,10 @@ func (e *Engine) JoinRing(ctx context.Context, targetAddr string, ji JoinInfo) e
|
||||
// JoinRingAddr wraps JoinRing for HTTP handlers: it builds the newcomer's
|
||||
// JoinInfo from this node's own id/addr/version/cache (so the caller doesn't
|
||||
// touch the engine's unexported fields) and asks targetAddr to sponsor us
|
||||
// into its ring. This is the runtime "加入集群" path (vs. the startup
|
||||
// into its ring. joinKey is the sponsor's nodeKey — the sponsor verifies it
|
||||
// before admitting. This is the runtime "加入集群" path (vs. the startup
|
||||
// bootstrap call in cmd/webui4frpc/main.go).
|
||||
func (e *Engine) JoinRingAddr(ctx context.Context, targetAddr string) error {
|
||||
ji := JoinInfo{ID: e.ID, Addr: e.myAddr, Version: e.Version, Cache: e.Cache}
|
||||
func (e *Engine) JoinRingAddr(ctx context.Context, targetAddr, joinKey string) error {
|
||||
ji := JoinInfo{ID: e.ID, Addr: e.myAddr, Version: e.Version, Cache: e.Cache, JoinKey: joinKey}
|
||||
return e.JoinRing(ctx, targetAddr, ji)
|
||||
}
|
||||
|
||||
129
internal/httpapi/auth.go
Normal file
129
internal/httpapi/auth.go
Normal file
@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// identity is the authenticated principal for a request, stashed in the
|
||||
// request context so handlers can read who/what level the caller is.
|
||||
//
|
||||
// Type is one of:
|
||||
// - "user": a row in the users table, authenticated via Basic + bcrypt.
|
||||
// - "service": the -user/-password flag creds (inter-node cluster traffic
|
||||
// and bootstrap), authenticated via a constant-time compare.
|
||||
// - "key": an API key, authenticated via Bearer + sha256 lookup.
|
||||
//
|
||||
// Level is the effective access tier: "read" | "write" | "admin". For users it
|
||||
// derives from the role (viewer -> read, admin -> admin); for keys it is the
|
||||
// key's scope; for the service identity it is always admin (the flags are the
|
||||
// built-in operator).
|
||||
type identity struct {
|
||||
Type string `json:"type"` // "user" | "service" | "key"
|
||||
Name string `json:"name"` // username / flag user / key label
|
||||
Level string `json:"level"` // "read" | "write" | "admin"
|
||||
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// identityFrom returns the authenticated identity from the request context,
|
||||
// or a zero identity (Level=="") when unauthenticated.
|
||||
func identityFrom(r *http.Request) identity {
|
||||
if v, ok := r.Context().Value(ctxKey{}).(identity); ok {
|
||||
return v
|
||||
}
|
||||
return identity{}
|
||||
}
|
||||
|
||||
// levelRank orders the access tiers: read < write < admin. Unknown = 0.
|
||||
func levelRank(level string) int {
|
||||
switch level {
|
||||
case "read":
|
||||
return 1
|
||||
case "write":
|
||||
return 2
|
||||
case "admin":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// roleLevel maps a user role to an access level (viewer -> read, admin -> admin).
|
||||
func roleLevel(role string) string {
|
||||
if role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
// hasLevel reports whether the request's identity meets minLevel. Handlers
|
||||
// serving both read and mutating methods on one path use this to gate the
|
||||
// mutating branch (the route itself is registered at the read level so that
|
||||
// viewer GETs succeed, then PUT/POST/DELETE branches re-check here).
|
||||
func hasLevel(r *http.Request, minLevel string) bool {
|
||||
return levelRank(identityFrom(r).Level) >= levelRank(minLevel)
|
||||
}
|
||||
|
||||
// forbidden writes a 403 with a small JSON body.
|
||||
func forbidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`))
|
||||
}
|
||||
|
||||
// auth wraps a handler with authentication AND authorization. It accepts either
|
||||
// HTTP Basic (users table bcrypt, with a flag-creds admin fast path that keeps
|
||||
// inter-node cluster traffic working) or a Bearer API key (sha256-looked-up).
|
||||
// Identities below minLevel get 403; missing/bad credentials get 401.
|
||||
//
|
||||
// The flag-creds fast path is checked BEFORE the users table: inter-node token
|
||||
// relay and the browser-cached Basic header hit it on every request, and a
|
||||
// bcrypt verify per hop would be wasteful; the flags are the built-in operator
|
||||
// and are synced into a system=1 admin row by SyncSystemUser anyway, so this
|
||||
// shortcut grants no privilege that the flags themselves do not already confer.
|
||||
func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
need := levelRank(minLevel)
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id identity
|
||||
switch {
|
||||
case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "):
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 {
|
||||
id = identity{Type: "service", Name: h.User, Level: "admin"}
|
||||
} else if usr, ok := h.Store.VerifyUserPassword(u, p); ok {
|
||||
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
||||
_ = h.Store.TouchUserLogin(usr.ID)
|
||||
}
|
||||
case strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "):
|
||||
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if k, usr, ok := h.Store.LookupApiKey(store.HashApiKey(key)); ok {
|
||||
id = identity{Type: "key", Name: k.Label, Level: k.Scope, UserID: usr.ID}
|
||||
_ = h.Store.TouchApiKey(k.ID)
|
||||
}
|
||||
}
|
||||
if id.Level == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
if levelRank(id.Level) < need {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKey{}, id)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
Normal file
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/httpapi/dist/assets/index-CL42Ur3_.css
vendored
Normal file
1
internal/httpapi/dist/assets/index-CL42Ur3_.css
vendored
Normal file
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-CXBf2fLP.js
vendored
76
internal/httpapi/dist/assets/index-CXBf2fLP.js
vendored
File diff suppressed because one or more lines are too long
283
internal/httpapi/dist/favicon.svg
vendored
Normal file
283
internal/httpapi/dist/favicon.svg
vendored
Normal file
@ -0,0 +1,283 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||
<defs>
|
||||
<!-- 主字母像素块(黑色) -->
|
||||
<rect id="p" width="12" height="12" fill="#000000" />
|
||||
<!-- frpc 小像素块(蓝色) -->
|
||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
||||
<!-- 装饰小方块(浅灰) -->
|
||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||
<!-- 小十字星装饰 -->
|
||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||
</defs>
|
||||
|
||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||
|
||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
||||
<g transform="translate(70, 160)">
|
||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
||||
<g transform="translate(0, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 中间 V 形(从 y=60 开始) -->
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="48" y="60" />
|
||||
<use href="#p" x="72" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="48" y="72" />
|
||||
<use href="#p" x="72" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="48" y="84" />
|
||||
<use href="#p" x="72" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="48" y="96" />
|
||||
<use href="#p" x="72" y="96" />
|
||||
<!-- 底部汇合 -->
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="60" y="108" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
<use href="#p" x="60" y="120" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
</g>
|
||||
|
||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
||||
<g transform="translate(124, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 底部横线 -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="72" y="132" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
||||
<g transform="translate(32, 18)">
|
||||
<!-- f -->
|
||||
<use href="#q" x="0" y="0" />
|
||||
<use href="#q" x="0" y="6" />
|
||||
<use href="#q" x="0" y="12" />
|
||||
<use href="#q" x="0" y="18" />
|
||||
<use href="#q" x="6" y="0" />
|
||||
<use href="#q" x="12" y="0" />
|
||||
<use href="#q" x="18" y="0" />
|
||||
<use href="#q" x="6" y="12" />
|
||||
<use href="#q" x="12" y="12" />
|
||||
|
||||
<!-- r -->
|
||||
<use href="#q" x="0" y="28" />
|
||||
<use href="#q" x="0" y="34" />
|
||||
<use href="#q" x="0" y="40" />
|
||||
<use href="#q" x="0" y="46" />
|
||||
<use href="#q" x="6" y="28" />
|
||||
<use href="#q" x="12" y="28" />
|
||||
<use href="#q" x="18" y="28" />
|
||||
<use href="#q" x="6" y="40" />
|
||||
<use href="#q" x="12" y="40" />
|
||||
|
||||
<!-- p -->
|
||||
<use href="#q" x="0" y="56" />
|
||||
<use href="#q" x="0" y="62" />
|
||||
<use href="#q" x="0" y="68" />
|
||||
<use href="#q" x="0" y="74" />
|
||||
<use href="#q" x="0" y="80" />
|
||||
<use href="#q" x="6" y="56" />
|
||||
<use href="#q" x="12" y="56" />
|
||||
<use href="#q" x="18" y="56" />
|
||||
<use href="#q" x="18" y="62" />
|
||||
<use href="#q" x="18" y="68" />
|
||||
<use href="#q" x="6" y="74" />
|
||||
<use href="#q" x="12" y="74" />
|
||||
<use href="#q" x="18" y="74" />
|
||||
|
||||
<!-- c -->
|
||||
<use href="#q" x="6" y="90" />
|
||||
<use href="#q" x="12" y="90" />
|
||||
<use href="#q" x="18" y="90" />
|
||||
<use href="#q" x="0" y="96" />
|
||||
<use href="#q" x="0" y="102" />
|
||||
<use href="#q" x="6" y="108" />
|
||||
<use href="#q" x="12" y="108" />
|
||||
<use href="#q" x="18" y="108" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
||||
<g transform="translate(248, 0)">
|
||||
<!-- 顶部横线(占6列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="24" y="0" />
|
||||
<use href="#p" x="36" y="0" />
|
||||
<use href="#p" x="48" y="0" />
|
||||
<use href="#p" x="60" y="0" />
|
||||
|
||||
<!-- 中间竖线(2 列宽) -->
|
||||
<use href="#p" x="24" y="12" />
|
||||
<use href="#p" x="36" y="12" />
|
||||
<use href="#p" x="24" y="24" />
|
||||
<use href="#p" x="36" y="24" />
|
||||
<use href="#p" x="24" y="36" />
|
||||
<use href="#p" x="36" y="36" />
|
||||
<use href="#p" x="24" y="48" />
|
||||
<use href="#p" x="36" y="48" />
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="36" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="36" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="36" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="36" y="96" />
|
||||
<use href="#p" x="24" y="108" />
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="24" y="120" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
|
||||
<!-- 底部横线(占6列) -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
||||
<!-- 左上角小十字星 -->
|
||||
<g transform="translate(40, 40)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
<!-- 右上角小方块 -->
|
||||
<use href="#d" x="440" y="40" />
|
||||
<!-- 左下角小方块 -->
|
||||
<use href="#d" x="40" y="440" />
|
||||
<!-- 右下角小十字星 -->
|
||||
<g transform="translate(436, 436)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
|
||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
||||
|
||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
7
internal/httpapi/dist/index.html
vendored
7
internal/httpapi/dist/index.html
vendored
@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-CXBf2fLP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B_Hvtf6C.css">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>webui4frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -29,6 +29,11 @@ func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
h.handleCanvasGet(w, r)
|
||||
case http.MethodPut:
|
||||
// Route is registered at read so viewer GETs work; PUT needs write.
|
||||
if !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
h.saveCanvas(w, r)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
@ -41,6 +46,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.applyCanvas(w, r, &canvas) {
|
||||
return
|
||||
}
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
|
||||
// applyCanvas performs the full-replace semantics shared by PUT /canvas and
|
||||
// POST /canvas/import: loopback rewrite, upsert locals + delete-missing (with
|
||||
// localOnly stop / cluster revoke), upsert remotes + delete-missing, wholesale
|
||||
// link replace, cluster task submit for non-localOnly forwards, and a
|
||||
// SyncWorkers pass. It writes errors to w and returns false on failure so the
|
||||
// caller knows not to write a success response.
|
||||
func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *canvasData) bool {
|
||||
s := h.Store
|
||||
|
||||
// Rewrite loopback backend addresses for cluster-distributed forwards.
|
||||
@ -57,7 +75,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
for _, l := range canvas.Locals {
|
||||
if err := s.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Delete locals not present. A removed localOnly forward is cancelled
|
||||
@ -101,7 +119,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
for _, rem := range canvas.Remotes {
|
||||
if err := s.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
if existing, err := s.ListRemotes(); err == nil {
|
||||
@ -119,32 +137,44 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
// Replace links wholesale.
|
||||
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Cluster distribution: non-localOnly forwards are submitted as token-ring
|
||||
// tasks (claimed by the lowest-load member). Local-only forwards are NOT
|
||||
// submitted — they stay on this node and are only visible here.
|
||||
// Cluster distribution: reconcile non-localOnly forwards against the ring
|
||||
// topology, respecting each link's Disabled flag. A non-disabled forward
|
||||
// not yet in topology is submitted (lowest-load member claims it); a
|
||||
// disabled forward present in topology is revoked. plan §画布差异判断,
|
||||
// extended so a per-forward stop made on the forwards page (disabled=true)
|
||||
// is not re-activated by a later canvas save. Local-only forwards are NOT
|
||||
// submitted — they stay on this node.
|
||||
localByName := map[string]store.Local{}
|
||||
for _, l := range canvas.Locals {
|
||||
localByName[l.Name] = l
|
||||
}
|
||||
remoteByName := map[string]store.Remote{}
|
||||
for _, r := range canvas.Remotes {
|
||||
remoteByName[r.Name] = r
|
||||
}
|
||||
if h.Ring != nil {
|
||||
for _, l := range canvas.Locals {
|
||||
if l.LocalOnly {
|
||||
for _, ln := range canvas.Links {
|
||||
loc, ok := localByName[ln.Local]
|
||||
if !ok || loc.LocalOnly {
|
||||
continue
|
||||
}
|
||||
for _, ln := range canvas.Links {
|
||||
if ln.Local != l.Name {
|
||||
continue
|
||||
}
|
||||
var rem store.Remote
|
||||
for _, rr := range canvas.Remotes {
|
||||
if rr.Name == ln.Remote {
|
||||
rem = rr
|
||||
break
|
||||
}
|
||||
}
|
||||
if rem.Name != "" {
|
||||
h.Ring.SubmitTask(l, rem, ln)
|
||||
}
|
||||
rem, ok := remoteByName[ln.Remote]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ln.Disabled {
|
||||
// Stopped on the forwards page: make sure it leaves the topology.
|
||||
if h.Ring.HasTask(ln.Local, ln.Remote, ln.RemotePort) {
|
||||
h.Ring.RevokeTask(loc, rem, ln)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// SubmitTask is idempotent (HasTask guard), so re-saving an active
|
||||
// canvas is a no-op for forwards already in the topology.
|
||||
h.Ring.SubmitTask(loc, rem, ln)
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,8 +183,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
|
||||
h.handleCanvasGet(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
@ -280,6 +309,12 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
case "start", "stop", "restart":
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
// Route registered at read so status/config/logs GETs work; worker
|
||||
// lifecycle mutations need write.
|
||||
if !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if action == "start" {
|
||||
err = h.Process.Start(name)
|
||||
|
||||
257
internal/httpapi/handlers_canvas.go
Normal file
257
internal/httpapi/handlers_canvas.go
Normal file
@ -0,0 +1,257 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/cluster"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// ---- Locals: single-resource CRUD ----
|
||||
|
||||
// handleLocals operates on /api/manager/locals: PUT upserts a single local.
|
||||
// Mirrors applyCanvas's per-local logic (loopback rewrite + ring task submit
|
||||
// for the local's links when it is a cluster forward, or a SyncWorkers pass
|
||||
// for a local-only forward).
|
||||
func (h *Handler) handleLocals(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var l store.Local
|
||||
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if l.Name == "" {
|
||||
http.Error(w, "local name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Loopback rewrite, same as applyCanvas: a non-localOnly forward must be
|
||||
// reachable from whichever cluster node claims it.
|
||||
if !l.LocalOnly && cluster.IsLoopbackIP(l.IP) {
|
||||
l.IP = cluster.RewriteForCluster(l.IP)
|
||||
}
|
||||
if err := h.Store.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Submit ring tasks for this local's existing links when it is a cluster
|
||||
// forward; local-only forwards just refresh their local worker.
|
||||
if !l.LocalOnly && h.Ring != nil {
|
||||
fwd, _ := h.Store.LinksForLocal(l.Name)
|
||||
for _, ln := range fwd {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.SubmitTask(l, rem, store.Link{
|
||||
Local: l.Name, Remote: rem.Name, RemotePort: ln.RemotePort,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
// handleLocalByName operates on /api/manager/locals/{name}: DELETE removes a
|
||||
// single local, mirroring applyCanvas's removed-local branch.
|
||||
func (h *Handler) handleLocalByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/locals/")
|
||||
if name == "" {
|
||||
http.Error(w, "local name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
l, ok := h.Store.GetLocal(name)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if l.LocalOnly {
|
||||
if h.Process != nil {
|
||||
if fwd, _ := h.Store.LinksForLocal(name); len(fwd) > 0 {
|
||||
_ = h.Process.Stop(fwd[0].Remote)
|
||||
}
|
||||
}
|
||||
} else if h.Ring != nil {
|
||||
fwd, _ := h.Store.LinksForLocal(name)
|
||||
for _, ln := range fwd {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.RevokeTask(l, rem, store.Link{
|
||||
Local: name, Remote: rem.Name, RemotePort: ln.RemotePort,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Store.DeleteLocal(name)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Links: single-resource CRUD ----
|
||||
|
||||
// handleLinks is the collection endpoint: POST adds a single link.
|
||||
// For a cluster (non-localOnly) forward it submits a ring task so the owning
|
||||
// node spawns the worker; for a local-only forward it just SyncWorkers so the
|
||||
// local frpc picks up the new proxy.
|
||||
func (h *Handler) handleLinks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var ln store.Link
|
||||
if err := json.NewDecoder(r.Body).Decode(&ln); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ln.Local == "" || ln.Remote == "" || ln.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := h.Store.GetLocal(ln.Local); !ok {
|
||||
http.Error(w, "local not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := h.Store.GetRemote(ln.Remote); !ok {
|
||||
http.Error(w, "remote not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Idempotent: an identical link already present is returned as-is.
|
||||
for _, existing := range mustListLinks(h.Store) {
|
||||
if existing.Local == ln.Local && existing.Remote == ln.Remote && existing.RemotePort == ln.RemotePort {
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
}
|
||||
created, err := h.Store.AddLink(ln)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if loc, ok := h.Store.GetLocal(ln.Local); ok {
|
||||
if !loc.LocalOnly && h.Ring != nil {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.SubmitTask(loc, rem, created)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// handleLinkByID operates on /api/manager/links/{id}: DELETE removes a single
|
||||
// link, revoking the cluster forward on its owning node when applicable.
|
||||
func (h *Handler) handleLinkByID(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/links/")
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid link id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ln, ok := h.Store.GetLink(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if loc, ok := h.Store.GetLocal(ln.Local); ok {
|
||||
if !loc.LocalOnly && h.Ring != nil {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.RevokeTask(loc, rem, ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Store.DeleteLink(id)
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
func mustListLinks(s *store.Store) []store.Link {
|
||||
links, _ := s.ListLinks()
|
||||
return links
|
||||
}
|
||||
|
||||
// ---- Canvas export / import ----
|
||||
|
||||
// canvasExportEnvelope wraps a canvas with provenance metadata for backup files.
|
||||
type canvasExportEnvelope struct {
|
||||
Type string `json:"_type"`
|
||||
Version int `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Exporter string `json:"exporter"`
|
||||
Canvas canvasData `json:"canvas"`
|
||||
}
|
||||
|
||||
// handleCanvasExport returns the full canvas wrapped in a metadata envelope and
|
||||
// as a downloadable attachment. Read-level (auditors can export).
|
||||
func (h *Handler) handleCanvasExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
links, _ := h.Store.ListLinks()
|
||||
env := canvasExportEnvelope{
|
||||
Type: "webui4frpc-canvas",
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().Unix(),
|
||||
Exporter: h.SelfAddr,
|
||||
Canvas: canvasData{Locals: locals, Remotes: remotes, Links: links},
|
||||
}
|
||||
body, err := json.MarshalIndent(env, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="webui4frpc-canvas.json"`)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// handleCanvasImport restores a previously exported canvas. Accepts either the
|
||||
// full envelope ({"_type":"webui4frpc-canvas","canvas":{...}}) or a bare canvas
|
||||
// ({locals,remotes,links}); both are applied via applyCanvas (full replace).
|
||||
func (h *Handler) handleCanvasImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Try the envelope first; fall back to a bare canvas ({locals,remotes,
|
||||
// links}). Probing for the envelope keeps both shapes working so a caller
|
||||
// can replay either a /canvas/export bundle or a raw PUT /canvas body.
|
||||
var env canvasExportEnvelope
|
||||
var canvas canvasData
|
||||
if jerr := json.Unmarshal(raw, &env); jerr == nil && env.Type == "webui4frpc-canvas" {
|
||||
canvas = env.Canvas
|
||||
} else if jerr := json.Unmarshal(raw, &canvas); jerr != nil {
|
||||
http.Error(w, "parse json: "+jerr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.applyCanvas(w, r, &canvas) {
|
||||
return
|
||||
}
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
278
internal/httpapi/handlers_forwards.go
Normal file
278
internal/httpapi/handlers_forwards.go
Normal file
@ -0,0 +1,278 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// forwardsReq is the {local,remote,remotePort} natural key identifying a single
|
||||
// forward. The triple is stable across canvas re-saves (unlike Link.ID, which
|
||||
// ReplaceLinks wholesale-replaces) and matches HasTask's keying.
|
||||
type forwardsReq struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
}
|
||||
|
||||
// groupReq selects a group for one-click start/stop on the forwards page.
|
||||
type groupReq struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// findLinkByTriple returns the link matching the (local,remote,remotePort)
|
||||
// natural key, or ok=false.
|
||||
func findLinkByTriple(s *store.Store, local, remote string, port int) (store.Link, bool) {
|
||||
links, err := s.ListLinks()
|
||||
if err != nil {
|
||||
return store.Link{}, false
|
||||
}
|
||||
for _, l := range links {
|
||||
if l.Local == local && l.Remote == remote && l.RemotePort == port {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return store.Link{}, false
|
||||
}
|
||||
|
||||
// startForward flips a forward to enabled and brings it up. A local-only
|
||||
// forward restarts/starts its local frpc worker so the re-enabled proxy is
|
||||
// rendered back in; a cluster (non-localOnly) forward is submitted to the ring
|
||||
// so the lowest-load member claims and spawns it (plan §令牌环协议). SubmitTask
|
||||
// is idempotent via HasTask. Returns store.ErrNotFound if the forward is gone.
|
||||
func (h *Handler) startForward(local, remote string, port int) error {
|
||||
ln, ok := findLinkByTriple(h.Store, local, remote, port)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
loc, ok := h.Store.GetLocal(local)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
rem, ok := h.Store.GetRemote(remote)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
_ = h.Store.SetLinkDisabled(local, remote, port, false)
|
||||
// Reflect the post-start (enabled) state in the snapshot handed to the
|
||||
// ring: ln was fetched before SetLinkDisabled, so for a re-start of a
|
||||
// previously stopped forward it still carries disabled=true. Without this
|
||||
// the topology entry would embed a stale disabled flag (cosmetically
|
||||
// wrong, and confusing if anything reads topology's link snapshot).
|
||||
ln.Disabled = false
|
||||
if loc.LocalOnly {
|
||||
if h.Process != nil {
|
||||
// Re-render (proxy re-added) on a running worker, or start it.
|
||||
if _, has := h.Process.Status(remote); has {
|
||||
_ = h.Process.Restart(remote)
|
||||
} else {
|
||||
_ = h.Process.Start(remote)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h.Ring != nil {
|
||||
h.Ring.SubmitTask(loc, rem, ln)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopForward flips a forward to disabled and tears it down. A local-only
|
||||
// forward restarts its worker so renderRemote omits the proxy (sibling forwards
|
||||
// on the same remote keep running — true per-forward stop); a cluster forward
|
||||
// is revoked so the owning node cancels its worker and drops it from the
|
||||
// topology (plan §任务撤销). RevokeTask is idempotent.
|
||||
func (h *Handler) stopForward(local, remote string, port int) error {
|
||||
ln, ok := findLinkByTriple(h.Store, local, remote, port)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
loc, ok := h.Store.GetLocal(local)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
rem, ok := h.Store.GetRemote(remote)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
_ = h.Store.SetLinkDisabled(local, remote, port, true)
|
||||
if loc.LocalOnly {
|
||||
// Re-render without this proxy; only meaningful while a worker runs.
|
||||
if h.Process != nil {
|
||||
if _, has := h.Process.Status(remote); has {
|
||||
_ = h.Process.Restart(remote)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h.Ring != nil {
|
||||
h.Ring.RevokeTask(loc, rem, ln)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleForwardsStart toggles one forward on.
|
||||
func (h *Handler) handleForwardsStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req forwardsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.startForward(req.Local, req.Remote, req.RemotePort); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsStop toggles one forward off.
|
||||
func (h *Handler) handleForwardsStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req forwardsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.stopForward(req.Local, req.Remote, req.RemotePort); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupStart starts every forward in a group with one click.
|
||||
func (h *Handler) handleForwardsGroupStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.startForward(ln.Local, ln.Remote, ln.RemotePort)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupStop stops every forward in a group with one click.
|
||||
func (h *Handler) handleForwardsGroupStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.stopForward(ln.Local, ln.Remote, ln.RemotePort)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// assignReq carries the group label to assign to a single forward (status
|
||||
// page group chip edit). Empty Group clears the assignment (移出分组).
|
||||
type assignReq struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleForwardsAssign changes the group label of a single forward. The
|
||||
// status page group chip is the quick entry; the canvas port editor also
|
||||
// carries a group field (both persist via the same store column). The
|
||||
// change is purely a DB update — no worker/ring action is needed (group is
|
||||
// a management label, not a runtime knob).
|
||||
func (h *Handler) handleForwardsAssign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req assignReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := findLinkByTriple(h.Store, req.Local, req.Remote, req.RemotePort); !ok {
|
||||
http.Error(w, store.ErrNotFound.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SetLinkGroup(req.Local, req.Remote, req.RemotePort, req.Group); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupDelete dissolves a group: every forward in the named
|
||||
// group is moved to 未分组 (grp=""). The group itself is not a stored entity
|
||||
// — it exists only as a label on links — so clearing all members is the
|
||||
// complete "delete". No worker/ring action (group is a management label).
|
||||
func (h *Handler) handleForwardsGroupDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Group == "" {
|
||||
http.Error(w, "group required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.Store.SetLinkGroup(ln.Local, ln.Remote, ln.RemotePort, "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
146
internal/httpapi/handlers_logs.go
Normal file
146
internal/httpapi/handlers_logs.go
Normal file
@ -0,0 +1,146 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workerLog is one frpc worker's log tail on a node.
|
||||
type workerLog struct {
|
||||
Name string `json:"name"`
|
||||
Remote string `json:"remote"` // worker key = remote name
|
||||
State string `json:"state"`
|
||||
Lines string `json:"lines"`
|
||||
}
|
||||
|
||||
// nodeLogsResp is the body of GET /node/logs: all frpc workers handling
|
||||
// forwards on THIS node (cluster-owned workers that run here + local-only ones).
|
||||
type nodeLogsResp struct {
|
||||
Node string `json:"node"`
|
||||
Workers []workerLog `json:"workers"`
|
||||
}
|
||||
|
||||
// localWorkerLogs gathers this node's frpc worker logs. Shared by /node/logs
|
||||
// and the /cluster/logs/export self entry so the requesting node doesn't HTTP
|
||||
// back to itself.
|
||||
func (h *Handler) localWorkerLogs() nodeLogsResp {
|
||||
out := nodeLogsResp{Node: h.SelfAddr, Workers: []workerLog{}}
|
||||
if h.Process == nil {
|
||||
return out
|
||||
}
|
||||
for _, name := range h.Process.ListWorkers() {
|
||||
st, _ := h.Process.Status(name)
|
||||
lines, _ := tailFile(h.Process.LogPath(name), 64*1024)
|
||||
out.Workers = append(out.Workers, workerLog{
|
||||
Name: name, Remote: name, State: st.State, Lines: lines,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleNodeLogs returns the frpc worker logs of THIS node only. Read-level:
|
||||
// auditors can pull it directly on any node, and the cluster export fans it out.
|
||||
func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.localWorkerLogs())
|
||||
}
|
||||
|
||||
// clusterWorkerLogNode is one node's entry in the export bundle.
|
||||
type clusterWorkerLogNode struct {
|
||||
ID string `json:"id"`
|
||||
Addr string `json:"addr"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Logs *nodeLogsResp `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// handleClusterLogsExport fans out GET /node/logs to every alive ring node
|
||||
// (using the flag Basic creds, which resolve to admin on each peer via the
|
||||
// flag fast path) and aggregates the results into a downloadable bundle.
|
||||
// Unreachable nodes are marked error but don't abort the export. The
|
||||
// requester's own logs are gathered locally without an HTTP round-trip.
|
||||
func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if h.Ring == nil {
|
||||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
snap := h.Ring.Snapshot()
|
||||
selfID := snap.SelfID
|
||||
type target struct{ id, addr string }
|
||||
var targets []target
|
||||
for _, n := range snap.Nodes {
|
||||
if !n.Alive || n.Addr == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, target{n.ID, n.Addr})
|
||||
}
|
||||
results := make([]clusterWorkerLogNode, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
cli := &http.Client{Timeout: 8 * time.Second}
|
||||
for i, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, t target) {
|
||||
defer wg.Done()
|
||||
entry := clusterWorkerLogNode{ID: t.id, Addr: t.addr}
|
||||
// Self: gather locally, no HTTP round-trip.
|
||||
if t.id == selfID {
|
||||
logs := h.localWorkerLogs()
|
||||
entry.OK = true
|
||||
entry.Logs = &logs
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+t.addr+apiPrefix+"/node/logs", nil)
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
req.SetBasicAuth(h.User, h.Password)
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
entry.Error = "unreachable: " + err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
entry.Error = "HTTP " + resp.Status
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
var logs nodeLogsResp
|
||||
if err := json.Unmarshal(body, &logs); err != nil {
|
||||
entry.Error = "parse: " + err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
entry.OK = true
|
||||
entry.Logs = &logs
|
||||
results[i] = entry
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
bundle := map[string]any{
|
||||
"_type": "webui4frpc-worker-logs",
|
||||
"version": 1,
|
||||
"exportedAt": time.Now().Unix(),
|
||||
"requester": selfID,
|
||||
"nodes": results,
|
||||
}
|
||||
body, _ := json.MarshalIndent(bundle, "", " ")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="worker-logs.json"`)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -57,68 +57,88 @@ const (
|
||||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != h.User || p != h.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// API routes (basic auth).
|
||||
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
||||
mux.HandleFunc(apiPrefix+"/settings", auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
// read tier (viewer + read-scope key + everyone above): pure-GET reads.
|
||||
// Routes that also accept a mutating method re-check the write level inside
|
||||
// the handler so a viewer GET still works while viewer PUT/POST is 403.
|
||||
mux.HandleFunc(apiPrefix+"/status", h.auth("read")(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", h.auth("read")(h.handleCanvasSave)) // GET read; PUT write-checked inside
|
||||
mux.HandleFunc(apiPrefix+"/canvas/export", h.auth("read")(h.handleCanvasExport))
|
||||
mux.HandleFunc(apiPrefix+"/canvas/import", h.auth("write")(h.handleCanvasImport))
|
||||
mux.HandleFunc(apiPrefix+"/locals", h.auth("write")(h.handleLocals))
|
||||
mux.HandleFunc(apiPrefix+"/locals/", h.auth("write")(h.handleLocalByName))
|
||||
mux.HandleFunc(apiPrefix+"/links", h.auth("write")(h.handleLinks))
|
||||
mux.HandleFunc(apiPrefix+"/links/", h.auth("write")(h.handleLinkByID))
|
||||
// Per-forward start/stop + one-click group start/stop. Decoupled from the
|
||||
// remote-node worker controls so the forwards page owns forward lifecycle
|
||||
// (local-only → local worker restart; cluster → ring submit/revoke).
|
||||
mux.HandleFunc(apiPrefix+"/forwards/start", h.auth("write")(h.handleForwardsStart))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/stop", h.auth("write")(h.handleForwardsStop))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/start", h.auth("write")(h.handleForwardsGroupStart))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/stop", h.auth("write")(h.handleForwardsGroupStop))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/assign", h.auth("write")(h.handleForwardsAssign))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/delete", h.auth("write")(h.handleForwardsGroupDelete))
|
||||
mux.HandleFunc(apiPrefix+"/settings", h.auth("read")(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut && !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
h.handleSettingsPut(w, r)
|
||||
return
|
||||
}
|
||||
h.handleSettingsGet(w, r)
|
||||
}))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", h.auth("read")(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", h.auth("write")(h.handleBinaryInstall))
|
||||
|
||||
// Profile lifecycle routes.
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
||||
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
||||
// Profile lifecycle: status/config/logs = read; start/stop/restart = write
|
||||
// (write-checked inside handleProfile).
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", h.auth("read")(h.handleProfile))
|
||||
|
||||
// M6: cluster nodes + per-node cached versions (UI + discovery).
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/task", auth(h.handleClusterTask))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/node-remove", auth(h.handleNodeRemove))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/create", auth(h.handleClusterCreate))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join-ring", auth(h.handleClusterJoinRing))
|
||||
// write tier (write-scope key / admin): single-resource mutation.
|
||||
mux.HandleFunc(apiPrefix+"/remotes", h.auth("write")(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", h.auth("write")(h.handleRemoteDelete))
|
||||
|
||||
// M6 cluster: reads + control plane. Inter-node token relay uses Basic flag
|
||||
// creds which resolve to admin via the flag fast path, so write level keeps
|
||||
// the ring working while blocking read-scope keys from injecting tasks.
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", h.auth("read")(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside
|
||||
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
|
||||
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/node-remove", h.auth("write")(h.handleNodeRemove))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/create", h.auth("write")(h.handleClusterCreate))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join-ring", h.auth("write")(h.handleClusterJoinRing))
|
||||
|
||||
// Account & API key management (admin only).
|
||||
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
|
||||
mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers))
|
||||
mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID))
|
||||
|
||||
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
||||
// Not under /api so peers hit it directly; auth still applied.
|
||||
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
|
||||
// Not under /api so peers hit it directly; auth still applied. Peers
|
||||
// resolve to admin via the flag fast path.
|
||||
mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary))
|
||||
|
||||
// Static assets (also basic auth) under /.
|
||||
// The SPA shell is served behind the SAME Basic Auth as the API (plan:
|
||||
// "healthz 免认证,其余 Basic Auth"). Without this, the browser loads the
|
||||
// page without a 401 challenge, never caches credentials, and every
|
||||
// same-origin /api/* fetch (credentials:"same-origin") gets 401 — so the
|
||||
// SPA renders but all data pages read as empty ("集群未启动"). Wrapping /
|
||||
// in auth makes the browser prompt once, cache the Basic header for the
|
||||
// origin, and send it on every subsequent asset + API request.
|
||||
mux.HandleFunc("/", auth(h.handleStatic))
|
||||
// Static assets behind the same auth as the API: the browser caches the
|
||||
// Basic header once and sends it on every asset + /api/* request, so the
|
||||
// SPA loads for any valid identity (viewer included).
|
||||
mux.HandleFunc("/", h.auth("read")(h.handleStatic))
|
||||
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
|
||||
// handleStatic serves the embedded web build. Paths map to dist files; / and
|
||||
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
|
||||
// routing, so returning index.html for "/" is enough — a redirect here would
|
||||
@ -207,6 +227,28 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
GroupCount int `json:"groupCount,omitempty"`
|
||||
}
|
||||
|
||||
// Build per-forward lookup tables: a localByName index for the forwards
|
||||
// array, and an ownerOf map from ring topology (keyed by the
|
||||
// local/remote/remotePort triple) so each forward card can show its owning
|
||||
// node and active state. plan §任务撤销 / §令牌环协议.
|
||||
localByName := make(map[string]store.Local, len(locals))
|
||||
for _, l := range locals {
|
||||
localByName[l.Name] = l
|
||||
}
|
||||
type topoKey struct{ local, remote string; port int }
|
||||
ownerOf := map[topoKey]string{}
|
||||
selfID := h.SelfAddr
|
||||
if h.Ring != nil {
|
||||
snap := h.Ring.Snapshot()
|
||||
selfID = snap.SelfID
|
||||
for _, e := range snap.Topology {
|
||||
k := topoKey{e.Local.Name, e.Remote.Name, e.Link.RemotePort}
|
||||
if _, ok := ownerOf[k]; !ok {
|
||||
ownerOf[k] = e.OwnerID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profiles := make([]profileStatus, 0, len(remotes))
|
||||
binary := ""
|
||||
if h.BinaryPath != nil {
|
||||
@ -236,7 +278,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Local status: each local plus its forwarding targets and whether each
|
||||
// target's worker is healthy (running).
|
||||
// target's worker is healthy (running). The per-forward owner/active detail
|
||||
// lives in the forwards array below; this is the local-services overview.
|
||||
type localTargetStatus struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
@ -265,6 +308,51 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
|
||||
}
|
||||
|
||||
// Forwards: one entry per link — the forward-centric view the UI renders as
|
||||
// cards. kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-
|
||||
// distributed); ownerId/active are resolved from ring topology (remote) or
|
||||
// the local worker (localOnly); group drives one-click group start/stop.
|
||||
type forwardStatus struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
LocalOnly bool `json:"localOnly"`
|
||||
Kind string `json:"kind"` // "local" | "remote"
|
||||
OwnerID string `json:"ownerId,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Group string `json:"group,omitempty"`
|
||||
LocalIP string `json:"localIp,omitempty"`
|
||||
LocalPort int `json:"localPort,omitempty"`
|
||||
LocalProto string `json:"localProto,omitempty"`
|
||||
}
|
||||
links, _ := h.Store.ListLinks()
|
||||
forwards := make([]forwardStatus, 0, len(links))
|
||||
for _, ln := range links {
|
||||
loc, ok := localByName[ln.Local]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fs := forwardStatus{
|
||||
Local: ln.Local, Remote: ln.Remote, RemotePort: ln.RemotePort,
|
||||
LocalOnly: loc.LocalOnly, Disabled: ln.Disabled, Group: ln.Group,
|
||||
LocalIP: loc.IP, LocalPort: loc.Port, LocalProto: loc.Protocol,
|
||||
}
|
||||
if loc.LocalOnly {
|
||||
fs.Kind = "local"
|
||||
fs.OwnerID = selfID
|
||||
if st, has := h.Process.Status(ln.Remote); has {
|
||||
fs.Active = !ln.Disabled && st.State == "running"
|
||||
}
|
||||
} else {
|
||||
fs.Kind = "remote"
|
||||
owner, inTopo := ownerOf[topoKey{ln.Local, ln.Remote, ln.RemotePort}]
|
||||
fs.OwnerID = owner
|
||||
fs.Active = !ln.Disabled && inTopo
|
||||
}
|
||||
forwards = append(forwards, fs)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"version": "0.1.0",
|
||||
"workDir": h.WorkDir,
|
||||
@ -274,6 +362,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"binaryPath": binary,
|
||||
"profiles": profiles,
|
||||
"localStatus": localStatuses,
|
||||
"forwards": forwards,
|
||||
"selfId": selfID,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@ -349,6 +439,11 @@ func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()})
|
||||
case http.MethodPost:
|
||||
// Route registered at read so GET works; cache pruning needs write.
|
||||
if !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
@ -379,13 +474,27 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Heartbeat ping: empty body (no token) is a liveness check from the
|
||||
// leader's predecessor — answer 200 without processing.
|
||||
// leader's predecessor (the FALLBACK leader-death path). A node that
|
||||
// restarted (CreateCluster → 1-node standalone) or detached
|
||||
// (detachAsStandalone → 1-node standalone) is NOT the multi-node ring
|
||||
// leader the predecessor thinks it is. Returning 409 makes the
|
||||
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
|
||||
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
|
||||
// 退出节点时,让leader上邻居意识到当前环已经没有节点了".
|
||||
if r.Body == nil {
|
||||
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
||||
http.Error(w, "standalone node", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
||||
http.Error(w, "standalone node", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@ -448,6 +557,14 @@ func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "parse join: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Verify the newcomer presents this node's admission key. Without this
|
||||
// check, any client that knows the shared webui password could join and
|
||||
// receive the full cluster picture — the key adds a per-node admission
|
||||
// layer the operator must copy from the sponsor's cluster page.
|
||||
if ji.JoinKey == "" || ji.JoinKey != h.Ring.NodeKey() {
|
||||
http.Error(w, "invalid join key", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
wasSingle := len(h.Ring.State().Nodes) <= 1
|
||||
state := h.Ring.JoinNode(ji)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"state": state})
|
||||
@ -547,7 +664,8 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addr string `json:"addr"`
|
||||
Addr string `json:"addr"`
|
||||
JoinKey string `json:"joinKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
|
||||
@ -557,13 +675,17 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "addr required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.JoinKey == "" {
|
||||
http.Error(w, "joinKey required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.Ring.IsMember() {
|
||||
http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
jc, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Ring.JoinRingAddr(jc, req.Addr); err != nil {
|
||||
if err := h.Ring.JoinRingAddr(jc, req.Addr, req.JoinKey); err != nil {
|
||||
http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@ -211,7 +211,7 @@ func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
|
||||
})
|
||||
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
|
||||
&cluster.AppHandler{}, func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
|
||||
"n1:7500", true)
|
||||
"n1:7500", true, "")
|
||||
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
|
||||
mux, _ := NewServeMux(h)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@ -272,6 +273,20 @@ func (m *Manager) Status(name string) (Status, bool) {
|
||||
return m.getStatus(w), true
|
||||
}
|
||||
|
||||
// ListWorkers returns the names of all workers currently under management
|
||||
// (running, starting, crashed-but-supervised, etc.), sorted for stable output.
|
||||
// Stopped workers are removed from the map and not listed.
|
||||
func (m *Manager) ListWorkers() []string {
|
||||
m.mu.Lock()
|
||||
out := make([]string, 0, len(m.workers))
|
||||
for name := range m.workers {
|
||||
out = append(out, name)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// StopAll stops all workers (used on manager shutdown).
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
|
||||
@ -2,13 +2,20 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@ -93,6 +100,14 @@ type Link struct {
|
||||
RemotePort int `json:"remotePort"`
|
||||
OffsetX int `json:"offsetX,omitempty"`
|
||||
OffsetY int `json:"offsetY,omitempty"`
|
||||
// Group is a user-facing management label for one-click group start/stop on
|
||||
// the forwards page (unrelated to frps load-balancing LBGroup on Local).
|
||||
Group string `json:"group,omitempty"`
|
||||
// Disabled marks a forward as stopped. renderRemote skips it (so a stopped
|
||||
// local-only forward drops just its own proxy), and applyCanvas reconciles
|
||||
// to topology respecting it (disabled forwards are not re-submitted). This
|
||||
// makes per-forward stop durable across canvas saves. Zero value = enabled.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// Settings holds runtime options.
|
||||
@ -101,6 +116,18 @@ type Settings struct {
|
||||
RestartOnExit bool `json:"restartOnExit"`
|
||||
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
|
||||
BinaryPath string `json:"binaryPath,omitempty"`
|
||||
// NodeKey is this node's cluster admission key. A newcomer must present
|
||||
// the sponsor's NodeKey to join via it (handleClusterJoin verifies).
|
||||
// Generated on first startup, persisted, stable across restarts so the
|
||||
// -join-key bootstrap path stays valid. Unrelated to the webui Basic-Auth
|
||||
// creds (-user/-password), which remain the transport-level credential.
|
||||
NodeKey string `json:"nodeKey,omitempty"`
|
||||
// ClusterPeers is a JSON array of {addr,key} pairs for all known cluster
|
||||
// peers, persisted on every token cycle. On crash/restart the node reads
|
||||
// this and tries to rejoin via any cached peer (presenting that peer's
|
||||
// key). Cleared on explicit detach (detachAsStandalone) so a node that
|
||||
// intentionally left does NOT auto-rejoin.
|
||||
ClusterPeers string `json:"clusterPeers,omitempty"`
|
||||
}
|
||||
|
||||
// Forward is a rendered link row attached to a remote.
|
||||
@ -110,6 +137,37 @@ type Forward struct {
|
||||
LocalPort int `json:"localPort,omitempty"`
|
||||
OffsetX int `json:"offsetX,omitempty"`
|
||||
OffsetY int `json:"offsetY,omitempty"`
|
||||
// Disabled mirrors the link's Disabled so renderRemote can skip stopped
|
||||
// forwards when building the frpc proxy list for a remote.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
// User is an authenticated account. Role gates UI/API access (admin = full,
|
||||
// viewer = read-only + exports, for auditors). System users are synced from
|
||||
// the -user/-password flags and are read-only in the account-management UI.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"` // never serialized to clients
|
||||
Role string `json:"role"` // "admin" | "viewer"
|
||||
Enabled bool `json:"enabled"`
|
||||
System bool `json:"system"` // true = flag-synced, UI read-only
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastLoginAt int64 `json:"lastLoginAt"`
|
||||
}
|
||||
|
||||
// ApiKey is a bearer token bound to a user with an explicit scope. The
|
||||
// plaintext key is returned exactly once at creation; only its sha256 hash
|
||||
// and an 8-char display prefix are persisted.
|
||||
type ApiKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
Prefix string `json:"prefix"` // first 8 chars of plaintext, for display
|
||||
Label string `json:"label"`
|
||||
Scope string `json:"scope"` // "read" | "write" | "admin"
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastUsedAt int64 `json:"lastUsedAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"` // 0 = never expires
|
||||
}
|
||||
|
||||
const schema = `
|
||||
@ -158,13 +216,37 @@ CREATE TABLE IF NOT EXISTS links (
|
||||
remote TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE,
|
||||
remote_port INTEGER NOT NULL DEFAULT 0,
|
||||
offset_x INTEGER NOT NULL DEFAULT 0,
|
||||
offset_y INTEGER NOT NULL DEFAULT 0
|
||||
offset_y INTEGER NOT NULL DEFAULT 0,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_remote ON links(remote);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_login_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key_hash TEXT UNIQUE NOT NULL, -- sha256(plaintext) hex
|
||||
prefix TEXT NOT NULL DEFAULT '', -- first 8 chars of plaintext, for display
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'admin'
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0 -- 0 = never expires
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
|
||||
`
|
||||
|
||||
// Store is the SQLite persistence layer.
|
||||
@ -240,6 +322,10 @@ func (s *Store) migrate() error {
|
||||
"admin_user TEXT NOT NULL DEFAULT ''",
|
||||
"admin_password TEXT NOT NULL DEFAULT ''",
|
||||
},
|
||||
"links": {
|
||||
"grp TEXT NOT NULL DEFAULT ''",
|
||||
"disabled INTEGER NOT NULL DEFAULT 0",
|
||||
},
|
||||
}
|
||||
for table, cols := range tables {
|
||||
rows, err := s.db.Query("PRAGMA table_info(" + table + ")")
|
||||
@ -432,7 +518,7 @@ func (s *Store) DeleteRemote(name string) error {
|
||||
// ---- Links ----
|
||||
|
||||
func (s *Store) ListLinks() ([]Link, error) {
|
||||
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y FROM links")
|
||||
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -440,7 +526,7 @@ func (s *Store) ListLinks() ([]Link, error) {
|
||||
var out []Link
|
||||
for rows.Next() {
|
||||
var l Link
|
||||
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY); err != nil {
|
||||
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
@ -448,6 +534,37 @@ func (s *Store) ListLinks() ([]Link, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddLink inserts a single link row and returns it with the new id filled in.
|
||||
func (s *Store) AddLink(l Link) (Link, error) {
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
|
||||
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
|
||||
)
|
||||
if err != nil {
|
||||
return Link{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
l.ID = id
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetLink returns a single link by id.
|
||||
func (s *Store) GetLink(id int64) (Link, bool) {
|
||||
var l Link
|
||||
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE id = ?", id).
|
||||
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
|
||||
if err != nil {
|
||||
return Link{}, false
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
// DeleteLink removes a single link by id.
|
||||
func (s *Store) DeleteLink(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// LocalTarget describes one outgoing forward of a local service.
|
||||
type LocalTarget struct {
|
||||
Remote string `json:"remote"`
|
||||
@ -491,6 +608,7 @@ func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
|
||||
LocalPort: loc.Port,
|
||||
OffsetX: l.OffsetX,
|
||||
OffsetY: l.OffsetY,
|
||||
Disabled: l.Disabled,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@ -508,8 +626,8 @@ func (s *Store) ReplaceLinks(links []Link) error {
|
||||
}
|
||||
for _, l := range links {
|
||||
if _, err := tx.Exec(
|
||||
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y) VALUES(?,?,?,?,?)",
|
||||
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY,
|
||||
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y, grp, disabled) VALUES(?,?,?,?,?,?,?)",
|
||||
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY, l.Group, l.Disabled,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -517,6 +635,31 @@ func (s *Store) ReplaceLinks(links []Link) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SetLinkDisabled flips the disabled flag of a forward identified by its
|
||||
// (local, remote, remotePort) natural key. This is the persistence half of the
|
||||
// forwards-page start/stop toggle; the caller also drives the worker/ring side.
|
||||
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
||||
disabled, local, remote, port,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLinkGroup assigns a management group label to a forward identified by its
|
||||
// (local, remote, remotePort) natural key. Empty string clears the group
|
||||
// (moves the forward to 未分组). This is the persistence half of the
|
||||
// status-page group chip edit; the canvas editor also writes group via
|
||||
// saveCanvas. Group is for one-click start/stop on the forwards page only
|
||||
// (unrelated to frps load-balancing lbGroup on Local).
|
||||
func (s *Store) SetLinkGroup(local, remote string, port int, group string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE links SET grp = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
||||
group, local, remote, port,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
func (s *Store) Settings() (Settings, error) {
|
||||
@ -544,6 +687,28 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetNodeKey persists just the cluster admission key, preserving all other
|
||||
// settings. Used on first startup when the key is generated.
|
||||
func (s *Store) SetNodeKey(key string) error {
|
||||
st, err := s.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.NodeKey = key
|
||||
return s.UpdateSettings(st)
|
||||
}
|
||||
|
||||
// SetClusterPeers persists the cached peer list (JSON) so a crashed node can
|
||||
// auto-rejoin on restart. Pass "" to clear (explicit detach).
|
||||
func (s *Store) SetClusterPeers(peersJSON string) error {
|
||||
st, err := s.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.ClusterPeers = peersJSON
|
||||
return s.UpdateSettings(st)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrInvalid = errors.New("invalid argument")
|
||||
@ -556,3 +721,264 @@ func boolToInt(b bool) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
func (s *Store) ListUsers() ([]User, error) {
|
||||
rows, err := s.db.Query("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var en, sys int
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Enabled = en != 0
|
||||
u.System = sys != 0
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(username string) (User, bool) {
|
||||
var u User
|
||||
var en, sys int
|
||||
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE username = ?", username)
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
||||
return User{}, false
|
||||
}
|
||||
u.Enabled = en != 0
|
||||
u.System = sys != 0
|
||||
return u, true
|
||||
}
|
||||
|
||||
func (s *Store) GetUserByID(id int64) (User, bool) {
|
||||
var u User
|
||||
var en, sys int
|
||||
row := s.db.QueryRow("SELECT id, username, password_hash, role, enabled, system, created_at, last_login_at FROM users WHERE id = ?", id)
|
||||
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &en, &sys, &u.CreatedAt, &u.LastLoginAt); err != nil {
|
||||
return User{}, false
|
||||
}
|
||||
u.Enabled = en != 0
|
||||
u.System = sys != 0
|
||||
return u, true
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user, hashing the plaintext password with bcrypt.
|
||||
func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
|
||||
if username == "" || plainPassword == "" {
|
||||
return User{}, ErrInvalid
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
return User{}, ErrInvalid
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,0,?,0)",
|
||||
username, string(hash), role, now,
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return User{
|
||||
ID: id, Username: username, PasswordHash: string(hash),
|
||||
Role: role, Enabled: true, System: false, CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateUser modifies role/enabled and optionally resets the password.
|
||||
// System (flag-synced) users refuse password changes.
|
||||
func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword string) error {
|
||||
u, ok := s.GetUserByID(id)
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if role != "admin" && role != "viewer" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if u.System && plainPassword != "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if plainPassword != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
"UPDATE users SET password_hash=?, role=?, enabled=? WHERE id=?",
|
||||
string(hash), role, boolToInt(enabled), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET role=?, enabled=? WHERE id=?",
|
||||
role, boolToInt(enabled), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUser removes a user. System users are protected. Callers should guard
|
||||
// the last remaining admin with CountAdmins before deleting an admin.
|
||||
func (s *Store) DeleteUser(id int64) error {
|
||||
u, ok := s.GetUserByID(id)
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if u.System {
|
||||
return ErrInvalid
|
||||
}
|
||||
_, err := s.db.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
|
||||
func (s *Store) CountAdmins() (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SyncSystemUser upserts the flag-synced built-in admin account on every
|
||||
// startup so -user/-password changes propagate to the users table. A
|
||||
// pre-existing non-system row with the same name is left untouched; the
|
||||
// flag-creds fallback in the auth middleware still authenticates it.
|
||||
func (s *Store) SyncSystemUser(username, plainPassword string) error {
|
||||
if username == "" || plainPassword == "" {
|
||||
return ErrInvalid
|
||||
}
|
||||
existing, ok := s.GetUser(username)
|
||||
if ok && !existing.System {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if ok {
|
||||
_, err = s.db.Exec(
|
||||
"UPDATE users SET password_hash=?, role='admin', enabled=1, system=1 WHERE id=?",
|
||||
string(hash), existing.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO users(username, password_hash, role, enabled, system, created_at, last_login_at) VALUES(?,?,?,1,1,?,0)",
|
||||
username, string(hash), "admin", now,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// TouchUserLogin records a successful login timestamp.
|
||||
func (s *Store) TouchUserLogin(id int64) error {
|
||||
_, err := s.db.Exec("UPDATE users SET last_login_at = ? WHERE id = ?", time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyUserPassword returns the user when the bcrypt hash matches. Used by
|
||||
// the auth middleware's Basic branch.
|
||||
func (s *Store) VerifyUserPassword(username, plainPassword string) (User, bool) {
|
||||
u, ok := s.GetUser(username)
|
||||
if !ok || !u.Enabled {
|
||||
return User{}, false
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plainPassword)) != nil {
|
||||
return User{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// ---- API keys ----
|
||||
|
||||
func (s *Store) ListApiKeys() ([]ApiKey, error) {
|
||||
rows, err := s.db.Query("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ApiKey
|
||||
for rows.Next() {
|
||||
var k ApiKey
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateApiKey generates a 32-byte random key, stores its sha256 hash, and
|
||||
// returns the plaintext exactly once.
|
||||
func (s *Store) CreateApiKey(userID int64, label, scope string) (ApiKey, string, error) {
|
||||
if scope != "read" && scope != "write" && scope != "admin" {
|
||||
return ApiKey{}, "", ErrInvalid
|
||||
}
|
||||
if _, ok := s.GetUserByID(userID); !ok {
|
||||
return ApiKey{}, "", ErrNotFound
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
// "w4f_" prefix makes keys greppable/recognizable; base64 RawURL = no padding.
|
||||
plaintext := "w4f_" + base64.RawURLEncoding.EncodeToString(raw)
|
||||
hash := HashApiKey(plaintext)
|
||||
prefix := plaintext[:8]
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.Exec(
|
||||
"INSERT INTO api_keys(user_id, key_hash, prefix, label, scope, created_at, last_used_at, expires_at) VALUES(?,?,?,?,?,?,0,0)",
|
||||
userID, hash, prefix, label, scope, now,
|
||||
)
|
||||
if err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return ApiKey{
|
||||
ID: id, UserID: userID, Prefix: prefix, Label: label,
|
||||
Scope: scope, CreatedAt: now,
|
||||
}, plaintext, nil
|
||||
}
|
||||
|
||||
// LookupApiKey finds a key by the sha256 hex of its plaintext, validating
|
||||
// expiry and the owning user's enabled flag. Used by the Bearer branch.
|
||||
func (s *Store) LookupApiKey(hashHex string) (ApiKey, User, bool) {
|
||||
var k ApiKey
|
||||
row := s.db.QueryRow("SELECT id, user_id, prefix, label, scope, created_at, last_used_at, expires_at FROM api_keys WHERE key_hash = ?", hashHex)
|
||||
if err := row.Scan(&k.ID, &k.UserID, &k.Prefix, &k.Label, &k.Scope, &k.CreatedAt, &k.LastUsedAt, &k.ExpiresAt); err != nil {
|
||||
return ApiKey{}, User{}, false
|
||||
}
|
||||
if k.ExpiresAt != 0 && time.Now().Unix() > k.ExpiresAt {
|
||||
return ApiKey{}, User{}, false
|
||||
}
|
||||
u, ok := s.GetUserByID(k.UserID)
|
||||
if !ok || !u.Enabled {
|
||||
return ApiKey{}, User{}, false
|
||||
}
|
||||
return k, u, true
|
||||
}
|
||||
|
||||
// TouchApiKey records the last-used timestamp for a key.
|
||||
func (s *Store) TouchApiKey(id int64) error {
|
||||
_, err := s.db.Exec("UPDATE api_keys SET last_used_at = ? WHERE id = ?", time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteApiKey(id int64) error {
|
||||
_, err := s.db.Exec("DELETE FROM api_keys WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// HashApiKey computes the sha256 hex of a plaintext key (middleware helper).
|
||||
func HashApiKey(plaintext string) string {
|
||||
sum := sha256.Sum256([]byte(plaintext))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
114
internal/store/users_test.go
Normal file
114
internal/store/users_test.go
Normal file
@ -0,0 +1,114 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestUserCreateVerify(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
u, err := st.CreateUser("alice", "secret-pass", "viewer")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if u.Role != "viewer" || u.System || !u.Enabled {
|
||||
t.Fatalf("bad user: %+v", u)
|
||||
}
|
||||
// Wrong password must fail.
|
||||
if _, ok := st.VerifyUserPassword("alice", "wrong"); ok {
|
||||
t.Fatal("verify accepted wrong password")
|
||||
}
|
||||
// Correct password must succeed.
|
||||
got, ok := st.VerifyUserPassword("alice", "secret-pass")
|
||||
if !ok || got.Username != "alice" || got.Role != "viewer" {
|
||||
t.Fatalf("verify failed: %+v ok=%v", got, ok)
|
||||
}
|
||||
// Duplicate username must error.
|
||||
if _, err := st.CreateUser("alice", "x", "admin"); err == nil {
|
||||
t.Fatal("duplicate create should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUserSync(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if err := st.SyncSystemUser("admin", "flag-pass"); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
u, ok := st.GetUser("admin")
|
||||
if !ok || !u.System || u.Role != "admin" {
|
||||
t.Fatalf("system user not synced: %+v ok=%v", u, ok)
|
||||
}
|
||||
// Flag password change propagates on re-sync.
|
||||
if err := st.SyncSystemUser("admin", "new-flag-pass"); err != nil {
|
||||
t.Fatalf("re-sync: %v", err)
|
||||
}
|
||||
if _, ok := st.VerifyUserPassword("admin", "flag-pass"); ok {
|
||||
t.Fatal("old flag password still works after re-sync")
|
||||
}
|
||||
if _, ok := st.VerifyUserPassword("admin", "new-flag-pass"); !ok {
|
||||
t.Fatal("new flag password rejected after re-sync")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastAdminGuard(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
// System admin exists after sync; count must be 1.
|
||||
if err := st.SyncSystemUser("admin", "p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _ := st.CountAdmins()
|
||||
if n != 1 {
|
||||
t.Fatalf("admins = %d want 1", n)
|
||||
}
|
||||
// System user delete must be refused.
|
||||
if err := st.DeleteUser(1); err != ErrInvalid {
|
||||
t.Fatalf("delete system user: %v want ErrInvalid", err)
|
||||
}
|
||||
// Add a viewer, then disable the system admin: still 1 admin, allowed at
|
||||
// store level (handler enforces the guard, store just refuses system rows).
|
||||
if _, err := st.CreateUser("bob", "p", "viewer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiKeyCreateLookup(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
u, _ := st.CreateUser("carol", "p", "admin")
|
||||
k, plaintext, err := st.CreateApiKey(u.ID, "ci", "write")
|
||||
if err != nil {
|
||||
t.Fatalf("create key: %v", err)
|
||||
}
|
||||
if plaintext == "" || k.Prefix == "" {
|
||||
t.Fatalf("empty plaintext/prefix: %+v", k)
|
||||
}
|
||||
if plaintext[:8] != k.Prefix {
|
||||
t.Fatalf("prefix mismatch: %q vs %q", k.Prefix, plaintext[:8])
|
||||
}
|
||||
// Lookup via the sha256 of the plaintext must find the key + owner.
|
||||
found, owner, ok := st.LookupApiKey(HashApiKey(plaintext))
|
||||
if !ok || owner.ID != u.ID || found.Scope != "write" {
|
||||
t.Fatalf("lookup failed: %+v owner=%+v ok=%v", found, owner, ok)
|
||||
}
|
||||
// A bogus hash must miss.
|
||||
if _, _, ok := st.LookupApiKey(HashApiKey("not-a-real-key")); ok {
|
||||
t.Fatal("bogus key should not be found")
|
||||
}
|
||||
// Deleting the user cascades to the key (FK ON DELETE CASCADE).
|
||||
if err := st.DeleteUser(u.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
if _, _, ok := st.LookupApiKey(HashApiKey(plaintext)); ok {
|
||||
t.Fatal("key survived user deletion")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user