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:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -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[:])
}