mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-22 09:57: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:
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
1
internal/httpapi/dist/assets/index-D_HiVU-N.css
vendored
Normal file
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
76
internal/httpapi/dist/assets/index-Db-ysDeU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/httpapi/dist/index.html
vendored
Normal file
13
internal/httpapi/dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
283
internal/httpapi/handlers.go
Normal file
283
internal/httpapi/handlers.go
Normal file
@ -0,0 +1,283 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// canvasData is the full drawing canvas exchanged with the frontend.
|
||||
type canvasData struct {
|
||||
Locals []store.Local `json:"locals"`
|
||||
Remotes []store.Remote `json:"remotes"`
|
||||
Links []store.Link `json:"links"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) {
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
links, _ := h.Store.ListLinks()
|
||||
writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.handleCanvasGet(w, r)
|
||||
case http.MethodPut:
|
||||
h.saveCanvas(w, r)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
var canvas canvasData
|
||||
if err := json.NewDecoder(r.Body).Decode(&canvas); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s := h.Store
|
||||
|
||||
// Upsert locals.
|
||||
for _, l := range canvas.Locals {
|
||||
if err := s.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Delete locals not present.
|
||||
if existing, err := s.ListLocals(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, l := range canvas.Locals {
|
||||
keep[l.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteLocal(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert remotes.
|
||||
for _, rem := range canvas.Remotes {
|
||||
if err := s.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing, err := s.ListRemotes(); err == nil {
|
||||
keep := map[string]bool{}
|
||||
for _, rem := range canvas.Remotes {
|
||||
keep[rem.Name] = true
|
||||
}
|
||||
for _, old := range existing {
|
||||
if !keep[old.Name] {
|
||||
_ = s.DeleteRemote(old.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace links wholesale.
|
||||
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Restart affected running workers so changes take effect immediately.
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
settings, _ := h.Store.Settings()
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
||||
var st store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpdateSettings(st); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.handleSettingsGet(w, r)
|
||||
}
|
||||
|
||||
// ---- Binary ----
|
||||
|
||||
func (h *Handler) handleBinaryStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := map[string]any{"binaryPath": ""}
|
||||
if h.BinaryPath != nil {
|
||||
resp["binaryPath"] = h.BinaryPath()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBinaryInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if h.InstallBinary == nil {
|
||||
http.Error(w, "install not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
path, version, err := h.InstallBinary(req.Version)
|
||||
if err != nil {
|
||||
http.Error(w, "install failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "version": version})
|
||||
}
|
||||
|
||||
// ---- Single remote upsert/delete (used by the status page) ----
|
||||
|
||||
func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var rem store.Remote
|
||||
if err := json.NewDecoder(r.Body).Decode(&rem); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rem.Name == "" || rem.IP == "" || rem.Port <= 0 || rem.Port > 65535 {
|
||||
http.Error(w, "name/ip/port required, port in [1,65535]", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Store.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Start the worker if enabled.
|
||||
if rem.Enabled {
|
||||
_ = h.Process.Start(rem.Name)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rem)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/remotes/")
|
||||
if name == "" {
|
||||
http.Error(w, "remote name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = h.Process.Stop(name)
|
||||
if err := h.Store.DeleteRemote(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---- Profile lifecycle ----
|
||||
|
||||
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
// Path: /api/manager/profiles/{name}/{action?}
|
||||
rel := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.Error(w, "profile name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "":
|
||||
// GET profile status
|
||||
st, has := h.Process.Status(name)
|
||||
forwards, _ := h.Store.LinksForRemote(name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
|
||||
})
|
||||
case "start", "stop", "restart":
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var err error
|
||||
if action == "start" {
|
||||
err = h.Process.Start(name)
|
||||
} else if action == "stop" {
|
||||
err = h.Process.Stop(name)
|
||||
} else {
|
||||
err = h.Process.Restart(name)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
case "config":
|
||||
data, err := os.ReadFile(h.Process.ConfigPath(name))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
case "logs":
|
||||
path := h.Process.LogPath(name)
|
||||
data, err := tailFile(path, 64*1024)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(data))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
|
||||
|
||||
// tailFile returns the last maxBytes bytes of a file.
|
||||
func tailFile(path string, maxBytes int64) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
size := min(info.Size(), maxBytes)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, size)
|
||||
if _, err := f.ReadAt(buf, info.Size()-size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
213
internal/httpapi/server.go
Normal file
213
internal/httpapi/server.go
Normal file
@ -0,0 +1,213 @@
|
||||
// Package httpapi serves the web UI and REST API.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Handler bundles dependencies for the HTTP API.
|
||||
type Handler struct {
|
||||
Store *store.Store
|
||||
Process *process.Manager
|
||||
WorkDir string
|
||||
BinDir string
|
||||
User string
|
||||
Password string
|
||||
|
||||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||||
// avoid an import cycle with the install package.
|
||||
InstallBinary func(version string) (path, ver string, err error)
|
||||
// BinaryPath resolves the current worker binary.
|
||||
BinaryPath func() string
|
||||
// RunInstall is a hook to trigger canary tasks after canvas save.
|
||||
SyncWorkers func()
|
||||
}
|
||||
|
||||
const (
|
||||
healthzPath = "/healthz"
|
||||
apiPrefix = "/api/manager"
|
||||
)
|
||||
|
||||
// NewServeMux builds the full HTTP handler.
|
||||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != h.User || p != h.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// API routes (basic auth).
|
||||
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
||||
mux.HandleFunc(apiPrefix+"/settings", auth(h.handleSettingsGet))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
||||
|
||||
// Profile lifecycle routes.
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
||||
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
||||
|
||||
// Static assets (also basic auth) under /.
|
||||
mux.HandleFunc("/", h.handleStatic)
|
||||
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
// handleStatic serves the embedded web build. Paths map to dist files; / and
|
||||
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
|
||||
// routing, so returning index.html for "/" is enough — a redirect here would
|
||||
// loop (the hash fragment never reaches the server).
|
||||
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
http.Error(w, "assets unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
data, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
// SPA fallback: any non-file path serves index.html.
|
||||
data, err = fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
name = "index.html"
|
||||
}
|
||||
if ct := contentTypeFor(name); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func contentTypeFor(name string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
case strings.HasSuffix(name, ".js"):
|
||||
return "application/javascript"
|
||||
case strings.HasSuffix(name, ".css"):
|
||||
return "text/css"
|
||||
case strings.HasSuffix(name, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(name, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(name, ".ico"):
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
settings, _ := h.Store.Settings()
|
||||
|
||||
type profileStatus struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status process.Status `json:"process"`
|
||||
HasProc bool `json:"hasProcess"`
|
||||
Forwards []store.Forward `json:"forwards"`
|
||||
}
|
||||
|
||||
profiles := make([]profileStatus, 0, len(remotes))
|
||||
binary := ""
|
||||
if h.BinaryPath != nil {
|
||||
binary = h.BinaryPath()
|
||||
}
|
||||
for _, rv := range remotes {
|
||||
st, has := h.Process.Status(rv.Name)
|
||||
fwd, _ := h.Store.LinksForRemote(rv.Name)
|
||||
profiles = append(profiles, profileStatus{
|
||||
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
|
||||
})
|
||||
}
|
||||
|
||||
// Local status: each local plus its forwarding targets and whether each
|
||||
// target's worker is healthy (running).
|
||||
type localTargetStatus struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
WorkerState string `json:"workerState"`
|
||||
}
|
||||
type localStatus struct {
|
||||
Local store.Local `json:"local"`
|
||||
Targets []localTargetStatus `json:"targets"`
|
||||
}
|
||||
|
||||
localStatuses := make([]localStatus, 0, len(locals))
|
||||
for _, l := range locals {
|
||||
targets, err := h.Store.LinksForLocal(l.Name)
|
||||
if err != nil {
|
||||
targets = nil
|
||||
}
|
||||
ts := make([]localTargetStatus, 0, len(targets))
|
||||
for _, tg := range targets {
|
||||
st, _ := h.Process.Status(tg.Remote)
|
||||
ts = append(ts, localTargetStatus{
|
||||
Remote: tg.Remote,
|
||||
RemotePort: tg.RemotePort,
|
||||
WorkerState: st.State,
|
||||
})
|
||||
}
|
||||
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"version": "0.1.0",
|
||||
"workDir": h.WorkDir,
|
||||
"settings": settings,
|
||||
"services": locals,
|
||||
"remotes": remotes,
|
||||
"binaryPath": binary,
|
||||
"profiles": profiles,
|
||||
"localStatus": localStatuses,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = fmtJSONEncode(w, v)
|
||||
}
|
||||
|
||||
func fmtJSONEncode(w http.ResponseWriter, v any) error {
|
||||
return json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
99
internal/httpapi/server_test.go
Normal file
99
internal/httpapi/server_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/process"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*Handler, *httptest.Server) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := process.NewManager(process.Options{
|
||||
ConfigsDir: filepath.Join(dir, "configs"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
BinaryPath: func() string { return "" },
|
||||
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
||||
AutoRestart: func(string) bool { return false },
|
||||
RestartInterval: func() int { return 5 },
|
||||
})
|
||||
|
||||
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw"}
|
||||
mux, err := NewServeMux(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return h, ts
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
resp, err := http.Get(ts.URL + "/api/manager/status")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanvasRoundTrip(t *testing.T) {
|
||||
_, ts := newTestHandler(t)
|
||||
client := ts.Client()
|
||||
|
||||
body := `{
|
||||
"locals": [{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
|
||||
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||
"links": [{"local":"web","remote":"srv-a","remotePort":8080}]
|
||||
}`
|
||||
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||
req.SetBasicAuth("admin", "pw")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("save canvas status = %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got canvasData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Locals) != 1 || got.Locals[0].Name != "web" {
|
||||
t.Fatalf("locals = %+v", got.Locals)
|
||||
}
|
||||
if len(got.Links) != 1 || got.Links[0].RemotePort != 8080 {
|
||||
t.Fatalf("links = %+v", got.Links)
|
||||
}
|
||||
|
||||
// GET again to confirm persistence via store.
|
||||
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/canvas", nil)
|
||||
req2.SetBasicAuth("admin", "pw")
|
||||
resp2, err2 := client.Do(req2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
var got2 canvasData
|
||||
_ = json.NewDecoder(resp2.Body).Decode(&got2)
|
||||
if len(got2.Remotes) != 1 || got2.Remotes[0].Name != "srv-a" {
|
||||
t.Fatalf("remotes after reload = %+v", got2.Remotes)
|
||||
}
|
||||
}
|
||||
203
internal/install/install.go
Normal file
203
internal/install/install.go
Normal file
@ -0,0 +1,203 @@
|
||||
// Package install downloads official frpc binaries from GitHub releases.
|
||||
package install
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
releasesBase = "https://github.com/fatedier/frp/releases/download"
|
||||
// maxArchiveFileSize caps a single extracted file against decompression bombs.
|
||||
maxArchiveFileSize = 256 << 20 // 256 MiB
|
||||
)
|
||||
|
||||
// latestAPI is a var so tests can point it at a local server.
|
||||
var latestAPI = "https://api.github.com/repos/fatedier/frp/releases/latest"
|
||||
|
||||
// Install downloads the frpc release for the requested version (empty = latest)
|
||||
// into binDir, verifies it runs, and returns the path and installed version.
|
||||
func Install(ctx context.Context, binDir, version string) (path, installedVersion string, err error) {
|
||||
if version == "" {
|
||||
version, err = latestVersion()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve latest version: %w", err)
|
||||
}
|
||||
}
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
|
||||
platform := platformName()
|
||||
pkgName := fmt.Sprintf("frp_%s_%s.tar.gz", version, platform)
|
||||
url := fmt.Sprintf("%s/v%s/%s", releasesBase, version, pkgName)
|
||||
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tmpArchive := filepath.Join(binDir, pkgName+".download")
|
||||
if err := download(ctx, url, tmpArchive); err != nil {
|
||||
return "", "", fmt.Errorf("download %s: %w", url, err)
|
||||
}
|
||||
defer os.Remove(tmpArchive)
|
||||
|
||||
extractDir := filepath.Join(binDir, "frpc-"+version)
|
||||
if err := os.RemoveAll(extractDir); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := extractTarGz(tmpArchive, extractDir); err != nil {
|
||||
return "", "", fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
// Locate the frpc executable in the package's top-level directory.
|
||||
src, err := findFrpc(extractDir)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", err
|
||||
}
|
||||
dst := filepath.Join(extractDir, "frpc")
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", err
|
||||
}
|
||||
_ = os.Chmod(dst, 0o755)
|
||||
|
||||
if out, err := exec.Command(dst, "--version").Output(); err != nil {
|
||||
_ = os.RemoveAll(extractDir)
|
||||
return "", "", fmt.Errorf("downloaded binary not runnable: %w", err)
|
||||
} else if ver := strings.TrimSpace(string(out)); ver != "" {
|
||||
version = ver
|
||||
}
|
||||
return dst, version, nil
|
||||
}
|
||||
|
||||
// latestVersion returns the newest release tag from the GitHub API.
|
||||
func latestVersion() (string, error) {
|
||||
c := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := c.Get(latestAPI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rel.TagName, nil
|
||||
}
|
||||
|
||||
func download(ctx context.Context, url, path string) error {
|
||||
c := &http.Client{Timeout: 90 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return fmt.Errorf("unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func platformName() string {
|
||||
arch := runtime.GOARCH
|
||||
if arch == "x86_64" {
|
||||
arch = "amd64"
|
||||
}
|
||||
return runtime.GOOS + "_" + arch
|
||||
}
|
||||
|
||||
func extractTarGz(archive, dest string) error {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := filepath.Clean(hdr.Name)
|
||||
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
|
||||
continue // path traversal guard
|
||||
}
|
||||
target := filepath.Join(dest, name)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, io.LimitReader(tr, maxArchiveFileSize)); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
_ = out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findFrpc(dir string) (string, error) {
|
||||
var found string
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() && d.Name() == "frpc" {
|
||||
found = path
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("no frpc file under %s", dir)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
33
internal/install/install_test.go
Normal file
33
internal/install/install_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLatestVersion(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v0.71.0"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
old := latestAPI
|
||||
latestAPI = ts.URL
|
||||
defer func() { latestAPI = old }()
|
||||
|
||||
v, err := latestVersion()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != "v0.71.0" {
|
||||
t.Fatalf("version = %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformName(t *testing.T) {
|
||||
p := platformName()
|
||||
if p == "" || len(p) < 5 {
|
||||
t.Fatalf("platform = %q", p)
|
||||
}
|
||||
}
|
||||
293
internal/process/process.go
Normal file
293
internal/process/process.go
Normal file
@ -0,0 +1,293 @@
|
||||
// Package process supervises worker frpc processes, one per remote.
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status describes the current state of a worker process.
|
||||
type Status struct {
|
||||
State string `json:"state"` // stopped | starting | running | restarting | crashed
|
||||
Pid int `json:"pid,omitempty"`
|
||||
StartTime int64 `json:"startTime,omitempty"`
|
||||
RestartCount int `json:"restartCount"`
|
||||
ExitCode int `json:"exitCode,omitempty"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
stopGraceTimeout = 10 * time.Second
|
||||
maxRestartDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
// Options configures a Manager.
|
||||
type Options struct {
|
||||
// ConfigsDir is where rendered JSON configs are written.
|
||||
ConfigsDir string
|
||||
// LogsDir is where worker output is captured.
|
||||
LogsDir string
|
||||
// BinaryPath returns the frpc binary to spawn (resolved dynamically).
|
||||
BinaryPath func() string
|
||||
// Render returns the config bytes for a remote.
|
||||
Render func(remoteName string) ([]byte, error)
|
||||
// AutoRestart returns whether to auto-restart a remote's worker.
|
||||
AutoRestart func(remoteName string) bool
|
||||
// RestartInterval returns the base restart interval in seconds.
|
||||
RestartInterval func() int
|
||||
}
|
||||
|
||||
type worker struct {
|
||||
name string
|
||||
proc *exec.Cmd
|
||||
logFile *rotatingFile
|
||||
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
status Status
|
||||
}
|
||||
|
||||
// Manager supervises all workers.
|
||||
type Manager struct {
|
||||
opts Options
|
||||
mu sync.Mutex
|
||||
workers map[string]*worker
|
||||
}
|
||||
|
||||
// NewManager builds a worker supervisor.
|
||||
func NewManager(opts Options) *Manager {
|
||||
return &Manager{opts: opts, workers: make(map[string]*worker)}
|
||||
}
|
||||
|
||||
func (m *Manager) getStatus(w *worker) Status {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (m *Manager) setState(w *worker, s Status) {
|
||||
w.mu.Lock()
|
||||
w.status = s
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start renders and spawns a remote's worker. Idempotent if already running.
|
||||
func (m *Manager) Start(name string) error {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w != nil && statusRunning(w) {
|
||||
return nil
|
||||
}
|
||||
|
||||
w = &worker{
|
||||
name: name,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.workers[name] = w
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.spawn(w); err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.workers, name)
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusRunning(w *worker) bool {
|
||||
select {
|
||||
case <-w.doneCh:
|
||||
return false
|
||||
default:
|
||||
return w.proc != nil && w.proc.Process != nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) spawn(w *worker) error {
|
||||
data, err := m.opts.Render(w.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render config for %q: %w", w.name, err)
|
||||
}
|
||||
cfgPath := filepath.Join(m.opts.ConfigsDir, w.name+".json")
|
||||
if err := os.MkdirAll(m.opts.ConfigsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logWriter, err := newRotatingFile(filepath.Join(m.opts.LogsDir, w.name+".log"), 10*1024*1024)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(m.opts.BinaryPath(), "-c", cfgPath)
|
||||
cmd.Stdout = logWriter
|
||||
cmd.Stderr = logWriter
|
||||
setSysProcAttr(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = logWriter.Close()
|
||||
return fmt.Errorf("start worker %q: %w", w.name, err)
|
||||
}
|
||||
|
||||
restartCount := m.getStatus(w).RestartCount
|
||||
w.proc = cmd
|
||||
w.logFile = logWriter
|
||||
m.setState(w, Status{State: "running", Pid: cmd.Process.Pid, StartTime: time.Now().Unix(), RestartCount: restartCount})
|
||||
go m.supervise(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) supervise(w *worker) {
|
||||
defer close(w.doneCh)
|
||||
for {
|
||||
err := w.proc.Wait()
|
||||
exitCode := 0
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if w.logFile != nil {
|
||||
_ = w.logFile.Close()
|
||||
w.logFile = nil
|
||||
}
|
||||
w.proc = nil
|
||||
stopRequested := false
|
||||
select {
|
||||
case <-w.stopCh:
|
||||
stopRequested = true
|
||||
default:
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.setState(w, Status{State: "stopped", ExitCode: exitCode, RestartCount: m.getStatus(w).RestartCount})
|
||||
if stopRequested {
|
||||
return
|
||||
}
|
||||
if !m.opts.AutoRestart(w.name) {
|
||||
return
|
||||
}
|
||||
|
||||
rc := m.getStatus(w).RestartCount + 1
|
||||
m.setState(w, Status{State: "restarting", RestartCount: rc})
|
||||
select {
|
||||
case <-time.After(m.restartDelay(rc)):
|
||||
case <-w.stopCh:
|
||||
m.setState(w, Status{State: "stopped", RestartCount: rc})
|
||||
return
|
||||
}
|
||||
if err := m.spawn(w); err != nil {
|
||||
m.setState(w, Status{State: "crashed", Err: err.Error(), RestartCount: rc})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) restartDelay(count int) time.Duration {
|
||||
base := 5 * time.Second
|
||||
if m.opts.RestartInterval != nil {
|
||||
if s := m.opts.RestartInterval(); s > 0 {
|
||||
base = time.Duration(s) * time.Second
|
||||
}
|
||||
}
|
||||
d := base
|
||||
for i := 1; i < count; i++ {
|
||||
d *= 2
|
||||
if d >= maxRestartDelay {
|
||||
return maxRestartDelay
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Stop gracefully stops a worker and waits for exit.
|
||||
func (m *Manager) Stop(name string) error {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
w.stopOnce.Do(func() { close(w.stopCh) })
|
||||
|
||||
m.mu.Lock()
|
||||
proc := w.proc
|
||||
m.mu.Unlock()
|
||||
if proc != nil {
|
||||
signalGroup(proc, syscall.SIGTERM)
|
||||
}
|
||||
select {
|
||||
case <-w.doneCh:
|
||||
case <-time.After(stopGraceTimeout):
|
||||
m.mu.Lock()
|
||||
proc := w.proc
|
||||
m.mu.Unlock()
|
||||
if proc != nil {
|
||||
signalGroup(proc, syscall.SIGKILL)
|
||||
}
|
||||
<-w.doneCh
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.workers, name)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops and starts a worker.
|
||||
func (m *Manager) Restart(name string) error {
|
||||
if err := m.Stop(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start(name)
|
||||
}
|
||||
|
||||
// Status returns the current status of a remote's worker.
|
||||
func (m *Manager) Status(name string) (Status, bool) {
|
||||
m.mu.Lock()
|
||||
w := m.workers[name]
|
||||
m.mu.Unlock()
|
||||
if w == nil {
|
||||
return Status{State: "stopped"}, false
|
||||
}
|
||||
return m.getStatus(w), true
|
||||
}
|
||||
|
||||
// StopAll stops all workers (used on manager shutdown).
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
names := make([]string, 0, len(m.workers))
|
||||
for n := range m.workers {
|
||||
names = append(names, n)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range names {
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
_ = m.Stop(name)
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// LogPath returns the log file path for a remote.
|
||||
func (m *Manager) LogPath(name string) string {
|
||||
return filepath.Join(m.opts.LogsDir, name+".log")
|
||||
}
|
||||
|
||||
// ConfigPath returns the rendered config path for a remote.
|
||||
func (m *Manager) ConfigPath(name string) string {
|
||||
return filepath.Join(m.opts.ConfigsDir, name+".json")
|
||||
}
|
||||
59
internal/process/process_test.go
Normal file
59
internal/process/process_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "fake-frpc")
|
||||
// A fake frpc that sleeps (simulating a healthy worker).
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := Options{
|
||||
ConfigsDir: filepath.Join(dir, "configs"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
BinaryPath: func() string { return script },
|
||||
Render: func(name string) ([]byte, error) {
|
||||
return []byte(`{"name":"` + name + `"}`), nil
|
||||
},
|
||||
AutoRestart: func(string) bool { return false },
|
||||
RestartInterval: func() int { return 1 },
|
||||
}
|
||||
return NewManager(opts)
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
pm := newTestManager(t)
|
||||
if err := pm.Start("srv-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, has := pm.Status("srv-a")
|
||||
if !has || st.State != "running" || st.Pid <= 0 {
|
||||
t.Fatalf("status = %+v has=%v", st, has)
|
||||
}
|
||||
if err := pm.Stop("srv-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, has = pm.Status("srv-a")
|
||||
if has {
|
||||
t.Fatalf("after stop has=%v", has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWritten(t *testing.T) {
|
||||
pm := newTestManager(t)
|
||||
_ = pm.Start("srv-b")
|
||||
defer pm.Stop("srv-b")
|
||||
data, err := os.ReadFile(pm.ConfigPath("srv-b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != `{"name":"srv-b"}` {
|
||||
t.Fatalf("config = %s", data)
|
||||
}
|
||||
}
|
||||
21
internal/process/process_unix.go
Normal file
21
internal/process/process_unix.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr runs the worker in its own process group.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// signalGroup delivers sig to the worker and its whole process group.
|
||||
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, sig)
|
||||
}
|
||||
21
internal/process/process_windows.go
Normal file
21
internal/process/process_windows.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build windows
|
||||
|
||||
package process
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setSysProcAttr runs the worker in a new process group on Windows.
|
||||
func setSysProcAttr(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
|
||||
}
|
||||
|
||||
// signalGroup signals only the direct process on Windows.
|
||||
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
|
||||
if cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Signal(sig)
|
||||
}
|
||||
73
internal/process/rotating.go
Normal file
73
internal/process/rotating.go
Normal file
@ -0,0 +1,73 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// rotatingFile appends to a log file and rotates it once it exceeds maxSize.
|
||||
type rotatingFile struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
maxSize int64
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
func newRotatingFile(path string, maxSize int64) (*rotatingFile, error) {
|
||||
if maxSize <= 0 {
|
||||
maxSize = 10 * 1024 * 1024
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &rotatingFile{path: path, maxSize: maxSize, f: f, size: info.Size()}, nil
|
||||
}
|
||||
|
||||
func (r *rotatingFile) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.f == nil {
|
||||
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.f = f
|
||||
}
|
||||
if r.size > 0 && r.size+int64(len(p)) > r.maxSize {
|
||||
_ = r.f.Close()
|
||||
_ = os.Rename(r.path, r.path+".1")
|
||||
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
r.f = nil
|
||||
return 0, err
|
||||
}
|
||||
r.f = f
|
||||
r.size = 0
|
||||
}
|
||||
n, err := r.f.Write(p)
|
||||
r.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *rotatingFile) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.f != nil {
|
||||
err := r.f.Close()
|
||||
r.f = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
104
internal/render/render.go
Normal file
104
internal/render/render.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Package render generates frpc worker configuration (JSON) from canvas data.
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// Proxy is a single proxy to render inside a worker config. It carries enough
|
||||
// info to build any supported frpc proxy type.
|
||||
type Proxy struct {
|
||||
// Name is the display name (the local service name).
|
||||
Name string
|
||||
// Type is the proxy type: tcp | udp | http | https.
|
||||
Type string
|
||||
// LocalIP and LocalPort are the backend address.
|
||||
LocalIP string
|
||||
LocalPort int
|
||||
// RemotePort is the exposed port on the frps server (tcp/udp only).
|
||||
RemotePort int
|
||||
// SubDomain is used by http/https proxies when set.
|
||||
SubDomain string
|
||||
// CustomDomains is used by http/https proxies when set.
|
||||
CustomDomains []string
|
||||
}
|
||||
|
||||
// frpcAuth mirrors frpc's auth section.
|
||||
type frpcAuth struct {
|
||||
Method string `json:"method"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// frpcTransport is the transport section used by frpc.
|
||||
type frpcTransport struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// frpcProxy is the serialized proxy object. Different types use different
|
||||
// fields; empty ones are omitted so the JSON is accepted by frpc.
|
||||
type frpcProxy struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"localIP,omitempty"`
|
||||
LocalPort int `json:"localPort"`
|
||||
RemotePort int `json:"remotePort,omitempty"`
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
SubDomain string `json:"subdomain,omitempty"`
|
||||
}
|
||||
|
||||
// Config is a complete frpc configuration document.
|
||||
type Config struct {
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerPort int `json:"serverPort"`
|
||||
Auth frpcAuth `json:"auth"`
|
||||
Transport frpcTransport `json:"transport,omitempty"`
|
||||
LoginFailExit bool `json:"loginFailExit"`
|
||||
Proxies []frpcProxy `json:"proxies"`
|
||||
}
|
||||
|
||||
// Render builds the worker config JSON for a remote server from its setting
|
||||
// plus the list of proxies to forward.
|
||||
func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
|
||||
cfg := Config{
|
||||
ServerAddr: remote.IP,
|
||||
ServerPort: remote.Port,
|
||||
Auth: frpcAuth{Method: "token", Token: remote.Token},
|
||||
LoginFailExit: false,
|
||||
Proxies: make([]frpcProxy, 0, len(proxies)),
|
||||
}
|
||||
|
||||
nameCounts := make(map[string]int)
|
||||
for _, p := range proxies {
|
||||
frpcP := frpcProxy{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
LocalIP: p.LocalIP,
|
||||
LocalPort: p.LocalPort,
|
||||
SubDomain: p.SubDomain,
|
||||
}
|
||||
// http/https proxies use customDomains instead of remotePort. The
|
||||
// default domain is <name>.local; frps matches it by Host header.
|
||||
if p.Type == "http" || p.Type == "https" {
|
||||
frpcP.RemotePort = 0
|
||||
if frpcP.SubDomain == "" && len(frpcP.CustomDomains) == 0 {
|
||||
frpcP.CustomDomains = []string{p.Name + ".local"}
|
||||
}
|
||||
} else {
|
||||
frpcP.RemotePort = p.RemotePort
|
||||
}
|
||||
if len(p.CustomDomains) > 0 {
|
||||
frpcP.CustomDomains = p.CustomDomains
|
||||
}
|
||||
// Duplicate service name -> suffix with the remote port so frpc accepts
|
||||
// multiple proxies for the same local service.
|
||||
nameCounts[frpcP.Name]++
|
||||
if nameCounts[frpcP.Name] > 1 {
|
||||
frpcP.Name = fmt.Sprintf("%s-%d", frpcP.Name, frpcP.RemotePort)
|
||||
}
|
||||
cfg.Proxies = append(cfg.Proxies, frpcP)
|
||||
}
|
||||
return json.MarshalIndent(cfg, "", " ")
|
||||
}
|
||||
48
internal/render/render_test.go
Normal file
48
internal/render/render_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
func TestRenderTCPProxy(t *testing.T) {
|
||||
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
|
||||
data, err := Render(remote, []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ServerAddr != "1.2.3.4" || cfg.Auth.Token != "secret" {
|
||||
t.Fatalf("cfg = %+v", cfg)
|
||||
}
|
||||
if len(cfg.Proxies) != 1 || cfg.Proxies[0].RemotePort != 8080 {
|
||||
t.Fatalf("proxies = %+v", cfg.Proxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
|
||||
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
|
||||
proxy := []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8081},
|
||||
}
|
||||
data, err := Render(remote, proxy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
_ = json.Unmarshal(data, &cfg)
|
||||
if len(cfg.Proxies) != 2 {
|
||||
t.Fatalf("proxies = %+v", cfg.Proxies)
|
||||
}
|
||||
if cfg.Proxies[1].Name != "web-8081" {
|
||||
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
60
internal/store/store_test.go
Normal file
60
internal/store/store_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalRemoteLinkRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := New(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if err := st.UpsertLocal(Local{Name: "web", IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.UpsertRemote(Remote{Name: "srv-a", IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ReplaceLinks([]Link{{Local: "web", Remote: "srv-a", RemotePort: 8080}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
locals, _ := st.ListLocals()
|
||||
if len(locals) != 1 || locals[0].Name != "web" {
|
||||
t.Fatalf("locals = %+v", locals)
|
||||
}
|
||||
remotes, _ := st.ListRemotes()
|
||||
if len(remotes) != 1 || remotes[0].Token != "tok" {
|
||||
t.Fatalf("remotes = %+v", remotes)
|
||||
}
|
||||
fwds, err := st.LinksForRemote("srv-a")
|
||||
if err != nil || len(fwds) != 1 {
|
||||
t.Fatalf("forwards = %+v err=%v", fwds, err)
|
||||
}
|
||||
if fwds[0].LocalPort != 8080 || fwds[0].RemotePort != 8080 {
|
||||
t.Fatalf("forward = %+v", fwds[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceLinksClearsOld(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := New(filepath.Join(dir, "test.db"))
|
||||
defer st.Close()
|
||||
_ = st.UpsertLocal(Local{Name: "a", IP: "127.0.0.1", Port: 1, Protocol: "tcp"})
|
||||
_ = st.UpsertLocal(Local{Name: "b", IP: "127.0.0.1", Port: 2, Protocol: "tcp"})
|
||||
_ = st.UpsertRemote(Remote{Name: "r", IP: "1.2.3.4", Port: 7000, Enabled: true})
|
||||
if err := st.ReplaceLinks([]Link{{Local: "a", Remote: "r", RemotePort: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ReplaceLinks([]Link{{Local: "b", Remote: "r", RemotePort: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
links, _ := st.ListLinks()
|
||||
if len(links) != 1 || links[0].Local != "b" {
|
||||
t.Fatalf("links = %+v", links)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user