mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus) - 画布多对多连线,渲染 tcp/udp/http/https frpc 配置 - worker 进程管理:自愈、日志轮转、崩溃退避重启 - frpc 一键安装 (GitHub Releases) + 手动指定路径 - 三页 UI:状态(默认)/连接配置/设置 - 状态页实时节点/转发状态,节点可增删改启停 - 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存 - backend 单测覆盖 store/render/process/httpapi/install - plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
336
internal/store/store.go
Normal file
336
internal/store/store.go
Normal file
@ -0,0 +1,336 @@
|
||||
// Package store implements the persistence layer for webui-frpc.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
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
|
||||
);
|
||||
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)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("secure db: %w", err)
|
||||
}
|
||||
return &Store{db: db}, 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 FROM locals ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Local
|
||||
for rows.Next() {
|
||||
var l Local
|
||||
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetLocal(name string) (Local, bool) {
|
||||
var l Local
|
||||
row := s.db.QueryRow("SELECT name, ip, port, protocol FROM locals WHERE name = ?", name)
|
||||
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
return Local{}, false
|
||||
}
|
||||
return l, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertLocal(l Local) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO locals(name, ip, port, protocol) VALUES(?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol",
|
||||
l.Name, l.IP, l.Port, l.Protocol,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
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 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 int
|
||||
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetRemote(name string) (Remote, bool) {
|
||||
var r Remote
|
||||
var en int
|
||||
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled FROM remotes WHERE name = ?", name)
|
||||
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
return Remote{}, false
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
return r, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRemote(r Remote) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO remotes(name, ip, port, token, url, enabled) VALUES(?,?,?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled",
|
||||
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled),
|
||||
)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user