// 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, ¬null, &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 }