Files
webui4frpc/internal/store/store.go
JianFeeeee c90d4f3791 feat(cluster,webapi): 集群页可切换负载均衡模式(每节点一个开关)
## 为什么之前没有开关(以及为什么现在这么加)

模式原本只读一次环境变量 `W4F_BALANCE_MODE`,UI 只显示「观察模式 / 已启用」。
把开关加进来时有两个坑必须一起解决,否则开关是个摆设:

1. **不持久化就等于骗人**:只用 env,页面上翻一下、重启一次就回到默认,
   而页面已经显示成功了。
2. **不能做成集群级开关**:每个成员只释放自己的转发,根本没有一个动作能把它们
   一起翻过来。做成集群开关就是撒谎。

所以:**每节点一个开关**,写的是本节点自己的设置。

## 持久化与优先级

- 新增 `settings` 表的 `balance_mode` 列 + `Store.BalanceMode()`。**单独一列**,
  不是塞进 settings JSON:那个 JSON 是整份重写的,而这是会被各节点同时写、必须
  各自收敛的状态,跟 `nodeKey`/`settings` 同一类处理。
- **存了就以存的为准**,env 只作首次启动的默认值;`Settings()` 读回时带上它,
  但 `UpdateSettings` 只在非空时镜像到列,所以一次无关的设置保存不会把它清掉。
- `SetModeFromUI` **先落盘再应用**:写失败就什么都不改,否则节点会运行一个重启后
  复现不出来的模式——开关就成了谎言。
- 认不出的模式一律拒绝(400),不猜。

## 报告新增两个字段

- `modeSource`: `env` | `stored` —— 说清当前模式是启动参数给的还是有人在这里选的。
- `canControl`: 由**调用者的权限**决定,只读身份不会看到一个点了会 403 的开关。

`PUT /cluster/balance/mode` 走 **write** 级鉴权:释放会停 worker、断活连接。

## 两处我自己抓到并修掉的错

1. **`ModeSource()` 一开始写成「能存就算 stored」**(判据是 `persist != nil`),
   于是**每个有 store 的节点都自称 stored**,哪怕从没人设过。canary 第一轮就露了:
   `mode=dry-run source=stored`。改成看「是否真的存过值」。
2. **启动日志打的是 env 变量而不是生效值**,存过 `on` 之后仍打 `DRY-RUN`——
   上一行的 `loaded from settings` 还在,两行自相矛盾,正好会让人以为释放是关的
   而它其实开着。

## 测试

新增 6 条:存的模式压过 env、没存就回落 env、落盘失败则模式不变、乱值被拒、
UI 切换同时落盘并生效、无持久化能力时不宣称可控制。
canary 实跑 6 步:初始 `dry-run/env` → 设 `on` → 复查 `on/stored` → 乱值 400 →
乱值后模式不变 → **重启后仍 `on/stored`**,启动日志 `load balancing ON (source=stored …)`。
vue-tsc 干净;go build / vet / go test ./... 全绿。
2026-10-06 14:37:18 +08:00

1103 lines
41 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"`
// BalanceMode is the load-balancing mode chosen in the UI: "", "off",
// "dry-run" or "on". Empty means "never configured here" so the environment
// variable stays the bootstrap default; a non-empty value wins over it,
// otherwise flipping the switch in the UI would silently revert on restart.
BalanceMode string `json:"balanceMode,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
}
// BalanceMode lives in its own column (see UpdateSettings). Surface it here
// too so callers reading Settings get the effective value, but never let it
// overwrite the column on a write-back: UpdateSettings only mirrors a
// non-empty value, so an unrelated settings PUT cannot clear a UI choice.
if mode, ok, err := s.BalanceMode(); err == nil && ok {
st.BalanceMode = mode
}
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),
)
if err != nil {
return err
}
if mode := st.BalanceMode; mode != "" {
// Mirror into its own column for the same reason the other flagged
// fields have one: the settings JSON is rewritten wholesale, so a
// clustered field that rides that JSON would flip back and forth between
// nodes instead of converging.
if _, err := s.db.Exec(
"INSERT INTO settings(key, value) VALUES('balance_mode', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
mode,
); err != nil {
return err
}
}
return nil
}
// BalanceMode returns the stored load-balancing mode, and whether one was ever
// stored (false = never configured here; the environment default applies).
func (s *Store) BalanceMode() (string, bool, error) {
var v string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'balance_mode'").Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, v != "", nil
}
// 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[:])
}