Files
webui4frpc/internal/store/store.go
JianFeeeee 1c835425de feat(cluster): 停用改为「标记」语义,让 disabled 真正随令牌环跨节点传播
承接用户提问「设计上停用不是本来就会跨节点传输吗」——核实结论:结构上确实
如此(TopoEntry.Link 是完整 store.Link,整个 State 随 token 每轮广播),但
实际路径断了。断点正是「撤销会删掉 topology 条目」:条目是 flag 的载体,
删了就无处传播,于是停用只能靠一次性 revoke 任务投递给 owner,**owner 当时
不在线就收不到**(实测 .60 记 disabled=1 / .106 记 0,就是这么来的)。

## 改为标记而非移除

撤销不再 RemoveTopology,而是 UpdateTopologyDisabled(true),条目保留、
Link.Disabled=true、Active=false。Active 正是为此存在:OfflineReassign()
只处理 Active 条目,所以停用的转发在 owner 掉线时不会被重新排队。

- 新增 UpdateTopologyDisabled / TopologyDisabled(照 UpdateTopologyGroup 的桥)
- 新增 store.ReconcileLinkDisabled 作接收端:adoption 时把环上的 flag 落进
  本地 store;本节点没有该转发时补一条 disabled 占位行(否则日后在本节点被
  claim 会复活),enable 则不建行
- SetTopologySync 由单向(store→环)扩为双向:群组仍上行,disabled 下行
- AddTopology 的 Active 跟随 Link.Disabled(原本硬编码 true,认领一个停用
  转发就会复活它)
- 审计日志细分 forward.stop / forward.start,与 forward.remove 区分

## 语义变更带出的两个新问题(都已修)

1. **「启动」这条路断了**。条目保留 ⇒ SubmitTask 被去重挡下,而认领路径的
   duplicate-claim 防御又会丢弃「已有 owner」的任务 ⇒ 重启任务发不出去,owner
   永远收不到,转发**能停不能起**。
   修:新增 Task.Restart 这一独立任务类型 + SubmitRestart + Handler.RestartFn,
   显式绕过 duplicate-claim 防御并原地复活(不重复建条目、不重跑 claim 簿记)。
   SubmitTask 的守卫同时从 HasTask 收窄为新的 HasActiveTask(跳过 disabled 条目
   与撤销任务);saveCanvas 的判断相应改用 HasActiveTask,避免每次保存都对
   已标记的转发重复发撤销。

2. 原本两处 RemoveTopologyEntry 调用(ClaimFn/RevokeFn 的 disabled 分支)在
   新语义下会把本该保留的条目删掉,改为 UpdateTopologyDisabled。

## 测试(每个都做了「回退修复行→必须变红→还原变绿」双向验证)

- TestStoppedTopologyEntrySurvivesAdoption —— 离线成员也能学到停用,
  一次性 revoke 任务永远做不到这一点
- TestStoppedForwardNotRequeuedOnNodeDeparture / TestAddTopologyRespectsDisabledFlag
  —— 标记而非删除为何安全
- TestSubmitTaskNotBlockedByStoppedEntry / TestSubmitTaskStillDedupesActiveForward
- TestRestartTaskBypassesDuplicateClaimGuard / TestRestartFlagSurvivesTokenSerialization
- TestStopThenStartPublishesRestartTask(HTTP 端到端,断言**任务真的发出**)
- TestReconcileLinkDisabled*(store 侧三条)

★ 两次踩到**假绿**:第一版只断言 store 层(newTestHandler 的 Ring 为 nil,
坏掉的路根本没执行);第二版在 re-enable **之后**才调 SubmitTask,此时新旧
谓词结果相同,测不出差异。都是靠「回退修复行看是否变红」抓出来的 —— 这个
双向验证已经是本项目的固定动作。

go build / go vet / go test ./... 全绿,gofmt 干净。
2026-09-26 10:44:04 +08:00

1062 lines
40 KiB
Go

// Package store implements the persistence layer for webui-frpc.
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"
)
// Local is a local forward service node on the canvas.
type Local struct {
Name string `json:"name"`
IP string `json:"ip"`
Port int `json:"port"`
Protocol string `json:"protocol"` // tcp | udp | http | https
// Advanced transport knobs (M1). All optional; empty/zero = frpc defaults.
UseEncryption bool `json:"useEncryption,omitempty"`
UseCompression bool `json:"useCompression,omitempty"`
BandwidthLimit string `json:"bandwidthLimit,omitempty"` // e.g. "1MB", "2KB"
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// M2 HTTP/HTTPS routing (http/https only).
CustomDomains string `json:"customDomains,omitempty"` // comma-separated
SubDomain string `json:"subdomain,omitempty"`
Locations []string `json:"locations,omitempty"` // path routing
HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"`
HTTPHeaders map[string]string `json:"httpHeaders,omitempty"`
BasicAuthUser string `json:"basicAuthUser,omitempty"`
BasicAuthPassword string `json:"basicAuthPassword,omitempty"`
// M3 Load balancing: group / groupKey turn this local into a group member.
// The frps load balances the group's proxies (many-to-one backend set).
LBGroup string `json:"lbGroup,omitempty"`
LBGroupKey string `json:"lbGroupKey,omitempty"`
// LocalOnly: when true the forward is created directly on this node
// (loopback stays 127.0.0.1), it does not enter the cluster token/topology,
// and is only visible in this node webui.
LocalOnly bool `json:"localOnly,omitempty"`
// Health check (frpc healthCheck): tcp probes LocalIP:LocalPort or an
// http GET to LocalIP:LocalPort + HealthCheckPath. When a group member
// fails its check, frpc stops routing to it; the server falls back to the
// surviving group members.
HealthCheckType string `json:"healthCheckType,omitempty"` // "" (off) | tcp | http
HealthCheckPath string `json:"healthCheckPath,omitempty"`
HealthCheckTimeout int `json:"healthCheckTimeout,omitempty"` // seconds, default 3
HealthCheckMaxFailed int `json:"healthCheckMaxFailed,omitempty"` // default 1
HealthCheckInterval int `json:"healthCheckInterval,omitempty"` // seconds, default 10
}
// Remote is a remote server node on the canvas.
type Remote struct {
Name string `json:"name"`
IP string `json:"ip"`
Port int `json:"port"` // port to connect to frps
Token string `json:"token,omitempty"`
URL string `json:"url,omitempty"`
Enabled bool `json:"enabled"`
// Transport section (M1). Protocol: tcp | quic | kcp | websocket.
TransportProtocol string `json:"transportProtocol,omitempty"`
TransportTLS bool `json:"transportTls,omitempty"`
TransportPool int `json:"transportPool,omitempty"`
TransportTLSServerName string `json:"transportTlsServerName,omitempty"`
// M2: frps vhostHTTPPort (optional, displayed on the status page as the
// HTTP access port for http/https services).
VhostHTTPPort int `json:"vhostHttpPort,omitempty"`
// M3: local frpc admin API (webServer). When AdminPort > 0 the worker
// exposes GET /api/status so we can report true per-proxy state
// (including health check results) instead of guessing from logs.
AdminAddr string `json:"adminAddr,omitempty"` // listen address, default 127.0.0.1
AdminPort int `json:"adminPort,omitempty"`
AdminUser string `json:"adminUser,omitempty"`
AdminPassword string `json:"adminPassword,omitempty"`
}
// Link connects one local to one remote.
type Link struct {
ID int64 `json:"id,omitempty"`
Local string `json:"local"`
Remote string `json:"remote"`
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.
type Settings struct {
AutoStartProfiles bool `json:"autoStartProfiles"`
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.
type Forward struct {
Service string `json:"service"`
RemotePort int `json:"remotePort"`
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 (superadmin =
// full + account management, admin = full except account management, 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" | "superadmin"
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 = `
CREATE TABLE IF NOT EXISTS locals (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp',
local_only INTEGER NOT NULL DEFAULT 0,
use_encryption INTEGER NOT NULL DEFAULT 0,
use_compression INTEGER NOT NULL DEFAULT 0,
bandwidth_limit TEXT NOT NULL DEFAULT '',
pool_count INTEGER NOT NULL DEFAULT 0,
metadatas TEXT NOT NULL DEFAULT '',
annotations TEXT NOT NULL DEFAULT '',
custom_domains TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
locations TEXT NOT NULL DEFAULT '',
host_header_rewrite TEXT NOT NULL DEFAULT '',
http_headers TEXT NOT NULL DEFAULT '',
basic_auth_user TEXT NOT NULL DEFAULT '',
basic_auth_password TEXT NOT NULL DEFAULT '',
lb_group TEXT NOT NULL DEFAULT '',
lb_group_key TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS remotes (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
token TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
transport_protocol TEXT NOT NULL DEFAULT '',
transport_tls INTEGER NOT NULL DEFAULT 0,
transport_pool INTEGER NOT NULL DEFAULT 0,
transport_tls_server_name TEXT NOT NULL DEFAULT '',
vhost_http_port INTEGER NOT NULL DEFAULT 0,
admin_addr TEXT NOT NULL DEFAULT '',
admin_port INTEGER NOT NULL DEFAULT 0,
admin_user TEXT NOT NULL DEFAULT '',
admin_password TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local TEXT NOT NULL REFERENCES locals(name) ON DELETE CASCADE,
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,
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
);
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' | 'superadmin'
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.
type Store struct {
db *sql.DB
}
// New opens the database at path, creating the schema if needed.
func New(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable fk: %w", err)
}
st := &Store{db: db}
if err := st.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate schema: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
db.Close()
return nil, fmt.Errorf("secure db: %w", err)
}
return st, nil
}
// migrate adds columns introduced after the initial schema so old databases
// keep working (CREATE TABLE IF NOT EXISTS does not touch existing tables).
func (s *Store) migrate() error {
tables := map[string][]string{
"locals": {
"use_encryption INTEGER NOT NULL DEFAULT 0",
"use_compression INTEGER NOT NULL DEFAULT 0",
"bandwidth_limit TEXT NOT NULL DEFAULT ''",
"pool_count INTEGER NOT NULL DEFAULT 0",
"metadatas TEXT NOT NULL DEFAULT ''",
"annotations TEXT NOT NULL DEFAULT ''",
"custom_domains TEXT NOT NULL DEFAULT ''",
"subdomain TEXT NOT NULL DEFAULT ''",
"locations TEXT NOT NULL DEFAULT ''",
"host_header_rewrite TEXT NOT NULL DEFAULT ''",
"http_headers TEXT NOT NULL DEFAULT ''",
"basic_auth_user TEXT NOT NULL DEFAULT ''",
"basic_auth_password TEXT NOT NULL DEFAULT ''",
"lb_group TEXT NOT NULL DEFAULT ''",
"lb_group_key TEXT NOT NULL DEFAULT ''",
"health_check_type TEXT NOT NULL DEFAULT ''",
"health_check_path TEXT NOT NULL DEFAULT ''",
"health_check_timeout INTEGER NOT NULL DEFAULT 0",
"health_check_max_failed INTEGER NOT NULL DEFAULT 0",
"health_check_interval INTEGER NOT NULL DEFAULT 0",
"local_only INTEGER NOT NULL DEFAULT 0",
},
"remotes": {
"transport_protocol TEXT NOT NULL DEFAULT ''",
"transport_tls INTEGER NOT NULL DEFAULT 0",
"transport_pool INTEGER NOT NULL DEFAULT 0",
"transport_tls_server_name TEXT NOT NULL DEFAULT ''",
"vhost_http_port INTEGER NOT NULL DEFAULT 0",
"admin_addr TEXT NOT NULL DEFAULT ''",
"admin_port INTEGER NOT NULL DEFAULT 0",
"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 + ")")
if err != nil {
return err
}
have := map[string]bool{}
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
have[name] = true
}
rows.Close()
for _, def := range cols {
name := def[:strings.Index(def, " ")]
if !have[name] {
if _, err := s.db.Exec("ALTER TABLE " + table + " ADD COLUMN " + def); err != nil {
return fmt.Errorf("migrate %s.%s: %w", table, name, err)
}
}
}
}
return nil
}
func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) {
rows, err := s.db.Query("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var out []Local
for rows.Next() {
var l Local
var enc, comp, localOnly int
var metaRaw, annoRaw, hdrRaw, locRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return nil, err
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.LocalOnly = localOnly != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) GetLocal(name string) (Local, bool) {
var l Local
var enc, comp, localOnly int
var metaRaw, annoRaw, hdrRaw, locRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return Local{}, false
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.LocalOnly = localOnly != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
l.HTTPHeaders = decodeMap(hdrRaw)
return l, true
}
func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, local_only=excluded.local_only, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.LocalOnly), boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
)
return err
}
// encodeSlice serializes an optional []string as JSON (empty -> "").
func encodeSlice(s []string) string {
if len(s) == 0 {
return ""
}
b, err := json.Marshal(s)
if err != nil {
return ""
}
return string(b)
}
// decodeSlice reads a JSON []string column; empty/invalid -> nil.
func decodeSlice(raw string) []string {
if raw == "" {
return nil
}
var s []string
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil
}
return s
}
// encodeMap serializes an optional JSON map so nil stays an empty string.
func encodeMap(m map[string]string) string {
if len(m) == 0 {
return ""
}
b, err := json.Marshal(m)
if err != nil {
return ""
}
return string(b)
}
// decodeMap reads a JSON map column; empty/invalid becomes nil.
func decodeMap(raw string) map[string]string {
if raw == "" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
return m
}
func (s *Store) DeleteLocal(name string) error {
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
return err
}
// ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) {
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var out []Remote
for rows.Next() {
var r Remote
var en, tls int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return nil, err
}
r.Enabled = en != 0
r.TransportTLS = tls != 0
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote
var en, tls int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return Remote{}, false
}
r.Enabled = en != 0
r.TransportTLS = tls != 0
return r, true
}
func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
)
return err
}
func (s *Store) DeleteRemote(name string) error {
_, err := s.db.Exec("DELETE FROM remotes WHERE name = ?", name)
return err
}
// ---- Links ----
func (s *Store) ListLinks() ([]Link, error) {
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
}
defer rows.Close()
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, &l.Group, &l.Disabled); err != nil {
return nil, err
}
out = append(out, l)
}
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
}
// LinkByTriple looks a link up by its natural key (local, remote, remotePort).
//
// Prefer this over GetLink(id) whenever the caller only knows the forward's
// identity: ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so
// every row gets a fresh autoincrement id. Any id captured before such a write
// (e.g. a Link carried inside a ring token) is stale by definition and will
// either miss or — worse — match a different forward. The natural key is
// stable across those rewrites.
//
// Returns (link, found). A missing row is (Link{}, false) and is NOT an error:
// callers use that to mean "no persisted opinion yet".
func (s *Store) LinkByTriple(local, remote string, port int) (Link, bool, error) {
var l Link
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE local = ? AND remote = ? AND remote_port = ?", local, remote, port).
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Link{}, false, nil
}
return Link{}, false, err
}
return l, true, nil
}
// 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"`
RemotePort int `json:"remotePort"`
}
// LinksForLocal returns the targets a local service forwards to.
func (s *Store) LinksForLocal(local string) ([]LocalTarget, error) {
links, err := s.ListLinks()
if err != nil {
return nil, err
}
var out []LocalTarget
for _, l := range links {
if l.Local != local {
continue
}
out = append(out, LocalTarget{Remote: l.Remote, RemotePort: l.RemotePort})
}
return out, nil
}
// LinksForRemote returns forwards of one remote with local port resolved.
func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
links, err := s.ListLinks()
if err != nil {
return nil, err
}
var out []Forward
for _, l := range links {
if l.Remote != remote {
continue
}
loc, ok := s.GetLocal(l.Local)
if !ok {
continue
}
out = append(out, Forward{
Service: l.Local,
RemotePort: l.RemotePort,
LocalPort: loc.Port,
OffsetX: l.OffsetX,
OffsetY: l.OffsetY,
Disabled: l.Disabled,
})
}
return out, nil
}
// ReplaceLinks clears all links and inserts the given set in one transaction.
func (s *Store) ReplaceLinks(links []Link) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("DELETE FROM links"); err != nil {
return err
}
for _, l := range links {
if _, err := tx.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,
); err != nil {
return err
}
}
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.
//
// Kept as a targeted UPDATE rather than a ReplaceLinks rewrite on purpose:
// ReplaceLinks deletes and reinserts every row, handing out fresh autoincrement
// ids and invalidating any Link a caller captured earlier (they travel inside
// ring tokens). Flipping one flag must not perturb other rows' identity.
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
}
// ReconcileLinkDisabled applies a cluster-wide view of one forward's disabled
// flag into the local store, creating a placeholder row when this node has none
// yet.
//
// This is the receive half of disabled-flag propagation. The stop decision is
// made on whichever node served the request, then rides the token ring in the
// topology entry; every other member calls this on adoption so its own
// links table agrees. Without it the flag lived only on the node that handled
// the request, and the node actually OWNS the forward — usually a different
// machine — still believed the forward was enabled and re-spawned its worker.
//
// A placeholder row is deliberate: a node that has never seen the forward still
// needs to remember "this is stopped" so a later claim on this node cannot
// resurrect it. The placeholder carries the same natural key, so a subsequent
// real claim fills in the rest.
func (s *Store) ReconcileLinkDisabled(local, remote string, port int, disabled bool) error {
cur, found, err := s.LinkByTriple(local, remote, port)
if err != nil {
return err
}
if found {
if cur.Disabled == disabled {
return nil // already agrees; avoid needless writes every token cycle
}
return s.SetLinkDisabled(local, remote, port, disabled)
}
if !disabled {
// Nothing to remember: an unknown forward with no entry is simply
// "not stopped", which is the default the claim path already assumes.
return nil
}
// Need a placeholder, which requires the local/remote foreign keys to exist.
if _, ok := s.GetLocal(local); !ok {
return nil // cannot materialise a link without its local peer row
}
if _, ok := s.GetRemote(remote); !ok {
return nil
}
links, err := s.ListLinks()
if err != nil {
return err
}
links = append(links, Link{Local: local, Remote: remote, RemotePort: port, Disabled: true})
return s.ReplaceLinks(links)
}
// 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) {
var st Settings
row := s.db.QueryRow("SELECT value FROM settings WHERE key = 'settings'")
var raw string
if err := row.Scan(&raw); err != nil {
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
}
if err := json.Unmarshal([]byte(raw), &st); err != nil {
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
}
return st, nil
}
func (s *Store) UpdateSettings(st Settings) error {
raw, err := json.Marshal(st)
if err != nil {
return err
}
_, err = s.db.Exec(
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
string(raw),
)
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")
ErrAlreadyExists = errors.New("already exists")
)
func boolToInt(b bool) int {
if b {
return 1
}
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" && role != "superadmin" {
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" && role != "superadmin" {
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/superadmin users (for the
// last-admin guard; both roles can manage others and must never be wiped out).
func (s *Store) CountAdmins() (int, error) {
var n int
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role IN ('admin','superadmin') 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[:])
}