Files
webui4frpc/internal/store/store.go

551 lines
20 KiB
Go

// Package store implements the persistence layer for webui-frpc.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
_ "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"`
// 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"`
}
// Settings holds runtime options.
type Settings struct {
AutoStartProfiles bool `json:"autoStartProfiles"`
RestartOnExit bool `json:"restartOnExit"`
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
BinaryPath string `json:"binaryPath,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"`
}
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',
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
);
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
);
`
// 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",
},
"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 ''",
},
}
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, 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 int
var metaRaw, annoRaw, hdrRaw, locRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &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.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 int
var metaRaw, annoRaw, hdrRaw, locRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, 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, &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.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, 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, 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.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 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); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.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,
})
}
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) VALUES(?,?,?,?,?)",
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY,
); err != nil {
return err
}
}
return tx.Commit()
}
// ---- 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
}
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
}