Files
webui4frpc/internal/store/store.go
jianf eda9bb9597 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)
2026-08-19 21:09:24 +08:00

985 lines
36 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 (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 = `
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'
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
}
// 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.
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) {
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" {
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[:])
}