feat: Frp 管理器支持可视化配置

frps/frpc 参数表单基于 confval 新增的 TOML 编解码就地改写主配置,保留注释与键顺序。
代理与访问者写入独立的 conf.d 目录,主配置只注入一行 includes,不接管其中手写的代理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-15 21:45:30 +08:00
parent db88d0d20b
commit a6c2a60e55
18 changed files with 2973 additions and 68 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ func initAce() (*app.Ace, func(), error) {
elasticsearchApp := elasticsearch.NewApp(locale)
websiteRepo := data.NewWebsiteRepo(db, locale, settingRepo)
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
frpApp := frp.NewApp()
frpApp := frp.NewApp(locale)
giteaApp := gitea.NewApp()
grafanaApp := grafana.NewApp(locale)
kafkaApp := kafka.NewApp(locale)
+1
View File
@@ -51,6 +51,7 @@ require (
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.1
github.com/orandin/slog-gorm v1.4.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pkg/sftp v1.13.11
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.53.0
+2
View File
@@ -335,6 +335,8 @@ github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsT
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+144
View File
@@ -0,0 +1,144 @@
package confval
import (
"strconv"
"strings"
"github.com/spf13/cast"
)
// tomlCodec TOML 根表的书写约定,值的引号由 formatTOML 按类型决定
var tomlCodec = Codec{Delim: "=", Assign: " = ", Comments: []string{"#"}, InlineComment: true}
// GetTOML 读取根表配置项,兼容点号键(auth.token = "x")与分组写法([auth] 下的 token = "x"
func GetTOML(content, key string) string {
section, leaf, nested := cutLast(key)
current := ""
for raw := range strings.SplitSeq(content, "\n") {
if name, ok := tableName(raw); ok {
current = name
continue
}
parsed := tomlCodec.parse(raw)
if !parsed.ok || parsed.commented != "" {
continue
}
// 根表下写点号全键,分组内写末段键
if (current == "" && parsed.key == key) || (nested && current == section && parsed.key == leaf) {
return unquoteTOML(parsed.value)
}
}
return ""
}
// SetTOML 写入根表配置项,值为空时注释掉该项;新键以点号形式插入到首个表头之前
func SetTOML(content, key string, value any) string {
literal := formatTOML(value)
section, leaf, nested := cutLast(key)
raws := strings.Split(content, "\n")
result := make([]string, 0, len(raws)+1)
current := ""
found := false
// 首个表头在 result 中的位置,新键只能插在它之前才仍属于根表
firstTable := -1
for _, raw := range raws {
if name, ok := tableName(raw); ok {
current = name
if firstTable < 0 {
firstTable = len(result)
}
result = append(result, raw)
continue
}
parsed := tomlCodec.parse(raw)
matched := parsed.ok && ((current == "" && parsed.key == key) || (nested && current == section && parsed.key == leaf))
if !matched || found {
result = append(result, raw)
continue
}
found = true
if literal == "" {
// 值为空时注释掉该配置项,已注释的原样保留
if parsed.commented == "" {
result = append(result, parsed.indent+"# "+strings.TrimSpace(raw))
} else {
result = append(result, raw)
}
continue
}
// 保留原行的写法、缩进与行尾注释,只替换值
result = append(result, parsed.indent+parsed.key+tomlCodec.Assign+literal+trailing(parsed.comment))
}
if found || literal == "" {
return strings.Join(result, "\n")
}
newLine := key + tomlCodec.Assign + literal
if firstTable < 0 {
return strings.Join(append(result, newLine), "\n")
}
return strings.Join(insertAt(result, firstTable, newLine), "\n")
}
// formatTOML 按 Go 类型输出 TOML 字面量,零值返回空串表示删除该项
func formatTOML(value any) string {
switch v := value.(type) {
case string:
if v == "" {
return ""
}
return strconv.Quote(v)
case bool:
return strconv.FormatBool(v)
case []string:
if len(v) == 0 {
return ""
}
quoted := make([]string, 0, len(v))
for _, item := range v {
quoted = append(quoted, strconv.Quote(item))
}
return "[" + strings.Join(quoted, ", ") + "]"
default:
return cast.ToString(v)
}
}
// unquoteTOML 去除字符串值的引号,非字符串值原样返回
func unquoteTOML(value string) string {
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
return value
}
// tableName 解析 [table] 与 [[table]] 行
func tableName(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "[") || !strings.HasSuffix(trimmed, "]") {
return "", false
}
return strings.Trim(strings.TrimSpace(strings.Trim(trimmed, "[]")), " \t"), true
}
// cutLast 切出点号键的分组前缀与末段,如 auth.token -> auth, token
func cutLast(key string) (section, leaf string, nested bool) {
idx := strings.LastIndex(key, ".")
if idx < 0 {
return "", key, false
}
return key[:idx], key[idx+1:], true
}
+85
View File
@@ -0,0 +1,85 @@
package confval
import (
"testing"
"github.com/stretchr/testify/suite"
)
type TOMLTestSuite struct {
suite.Suite
}
func TestTOMLTestSuite(t *testing.T) {
suite.Run(t, new(TOMLTestSuite))
}
func (s *TOMLTestSuite) TestGetDottedKey() {
conf := "bindPort = 7000\nauth.token = \"12345678\"\n# maxPortsPerClient = 0\n"
s.Equal("7000", GetTOML(conf, "bindPort"))
s.Equal("12345678", GetTOML(conf, "auth.token"))
// 注释掉的项视为未设置
s.Empty(GetTOML(conf, "maxPortsPerClient"))
s.Empty(GetTOML(conf, "absent"))
}
func (s *TOMLTestSuite) TestGetSectionKey() {
conf := "bindPort = 7000\n\n[auth]\nmethod = \"token\"\ntoken = \"abc\"\n"
s.Equal("token", GetTOML(conf, "auth.method"))
s.Equal("abc", GetTOML(conf, "auth.token"))
}
func (s *TOMLTestSuite) TestGetIgnoresProxyBlock() {
conf := "serverAddr = \"1.1.1.1\"\n\n[[proxies]]\nname = \"ssh\"\nserverAddr = \"2.2.2.2\"\n"
s.Equal("1.1.1.1", GetTOML(conf, "serverAddr"))
}
func (s *TOMLTestSuite) TestSetReplacesInPlace() {
conf := "# 监听端口\nbindPort = 7000 # 默认\nauth.token = \"old\"\n"
s.Equal("# 监听端口\nbindPort = 7001 # 默认\nauth.token = \"old\"\n", SetTOML(conf, "bindPort", 7001))
s.Equal("# 监听端口\nbindPort = 7000 # 默认\nauth.token = \"new\"\n", SetTOML(conf, "auth.token", "new"))
}
func (s *TOMLTestSuite) TestSetReplacesInSection() {
conf := "bindPort = 7000\n\n[auth]\ntoken = \"old\"\n"
s.Equal("bindPort = 7000\n\n[auth]\ntoken = \"new\"\n", SetTOML(conf, "auth.token", "new"))
}
func (s *TOMLTestSuite) TestSetInsertsBeforeFirstTable() {
conf := "bindPort = 7000\n\n[[proxies]]\nname = \"ssh\"\n"
s.Equal("bindPort = 7000\n\nauth.token = \"abc\"\n[[proxies]]\nname = \"ssh\"\n", SetTOML(conf, "auth.token", "abc"))
}
func (s *TOMLTestSuite) TestSetAppendsWhenNoTable() {
s.Equal("bindPort = 7000\nauth.token = \"abc\"", SetTOML("bindPort = 7000", "auth.token", "abc"))
}
func (s *TOMLTestSuite) TestSetCommentsOutEmptyValue() {
s.Equal("# bindPort = 7000", SetTOML("bindPort = 7000", "bindPort", ""))
s.Equal("# bindPort = 7000", SetTOML("# bindPort = 7000", "bindPort", ""))
// 键不存在时不应插入空值
s.Equal("bindPort = 7000", SetTOML("bindPort = 7000", "auth.token", ""))
}
func (s *TOMLTestSuite) TestSetReenablesCommented() {
s.Equal("maxPortsPerClient = 5", SetTOML("# maxPortsPerClient = 0", "maxPortsPerClient", 5))
}
func (s *TOMLTestSuite) TestSetBoolAndArray() {
s.Equal("bindPort = 7000\ntransport.tls.force = true", SetTOML("bindPort = 7000", "transport.tls.force", true))
s.Equal("bindPort = 7000\nincludes = [\"/opt/ace/server/frp/conf.d/*.toml\"]", SetTOML("bindPort = 7000", "includes", []string{"/opt/ace/server/frp/conf.d/*.toml"}))
}
func (s *TOMLTestSuite) TestSetDoesNotTouchProxyBlock() {
conf := "serverAddr = \"1.1.1.1\"\n\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n"
s.Equal("serverAddr = \"2.2.2.2\"\n\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n", SetTOML(conf, "serverAddr", "2.2.2.2"))
// serverPort 只存在于 proxies 表内,不应被改写,而是插到首个表头前
s.Equal("serverAddr = \"1.1.1.1\"\n\nserverPort = 7000\n[[proxies]]\n# 手写的隧道\nname = \"ssh\"\nserverPort = 22\n", SetTOML(conf, "serverPort", 7000))
}
+323 -6
View File
@@ -3,20 +3,27 @@ package frp
import (
"fmt"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
type App struct {
t *gotext.Locale
}
func NewApp() *App {
return &App{}
func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
}
}
func (s *App) Route(r chi.Router) {
@@ -24,6 +31,20 @@ func (s *App) Route(r chi.Router) {
r.Post("/config", s.UpdateConfig)
r.Get("/user", s.GetUser)
r.Post("/user", s.UpdateUser)
r.Get("/server", s.GetServer)
r.Post("/server", s.UpdateServer)
r.Get("/client", s.GetClient)
r.Post("/client", s.UpdateClient)
r.Get("/proxies", s.Proxies)
r.Post("/proxies", s.CreateProxy)
r.Get("/proxies/{name}", s.GetProxy)
r.Post("/proxies/{name}", s.UpdateProxy)
r.Delete("/proxies/{name}", s.DeleteProxy)
r.Get("/visitors", s.Visitors)
r.Post("/visitors", s.CreateVisitor)
r.Get("/visitors/{name}", s.GetVisitor)
r.Post("/visitors/{name}", s.UpdateVisitor)
r.Delete("/visitors/{name}", s.DeleteVisitor)
}
func (s *App) Status() string {
@@ -39,7 +60,7 @@ func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
return
}
config, err := io.Read(fmt.Sprintf("%s/server/frp/%s.toml", app.Root, req.Name))
config, err := io.Read(confPath(req.Name))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -55,7 +76,7 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/frp/%s.toml", app.Root, req.Name), req.Config, 0644); err != nil {
if err = io.Write(confPath(req.Name), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
@@ -147,3 +168,299 @@ func (s *App) UpdateUser(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
// GetServer 获取 frps 可视化参数
func (s *App) GetServer(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(confPath("frps"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := new(ServerTune)
readTune(config, serverFields(tune))
service.Success(w, tune)
}
// UpdateServer 更新 frps 可视化参数
func (s *App) UpdateServer(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ServerTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := confPath("frps")
config, err := io.Read(path)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(path, writeTune(config, serverFields(req)), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frps"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// GetClient 获取 frpc 可视化公共参数
func (s *App) GetClient(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(confPath("frpc"))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
tune := new(ClientTune)
readTune(config, clientFields(tune))
service.Success(w, tune)
}
// UpdateClient 更新 frpc 可视化公共参数
func (s *App) UpdateClient(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ClientTune](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
path := confPath("frpc")
config, err := io.Read(path)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(path, writeTune(config, clientFields(req)), 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// Proxies 代理列表
func (s *App) Proxies(w http.ResponseWriter, r *http.Request) {
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
paged, total := service.Paginate(r, confD.Proxies)
service.Success(w, chix.M{
"total": total,
"items": paged,
})
}
// GetProxy 获取单个代理
func (s *App) GetProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
proxy, ok := lo.Find(confD.Proxies, func(item Proxy) bool { return item.Name == req.Name })
if !ok {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s does not exist", req.Name))
return
}
service.Success(w, proxy)
}
// CreateProxy 新增代理
func (s *App) CreateProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Proxy](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if io.Exists(itemPath("proxy", req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s already exists", req.Name))
return
}
saveProxy(w, req)
}
// UpdateProxy 更新代理
func (s *App) UpdateProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Proxy](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !io.Exists(itemPath("proxy", req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("proxy %s does not exist", req.Name))
return
}
saveProxy(w, req)
}
// DeleteProxy 删除代理
func (s *App) DeleteProxy(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = os.Remove(itemPath("proxy", req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
// Visitors 访问者列表
func (s *App) Visitors(w http.ResponseWriter, r *http.Request) {
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
paged, total := service.Paginate(r, confD.Visitors)
service.Success(w, chix.M{
"total": total,
"items": paged,
})
}
// GetVisitor 获取单个访问者
func (s *App) GetVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
confD, err := listConfD()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
visitor, ok := lo.Find(confD.Visitors, func(item Visitor) bool { return item.Name == req.Name })
if !ok {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s does not exist", req.Name))
return
}
service.Success(w, visitor)
}
// CreateVisitor 新增访问者
func (s *App) CreateVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Visitor](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if io.Exists(itemPath("visitor", req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s already exists", req.Name))
return
}
saveVisitor(w, req)
}
// UpdateVisitor 更新访问者
func (s *App) UpdateVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Visitor](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if !io.Exists(itemPath("visitor", req.Name)) {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("visitor %s does not exist", req.Name))
return
}
saveVisitor(w, req)
}
// DeleteVisitor 删除访问者
func (s *App) DeleteVisitor(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ItemName](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = os.Remove(itemPath("visitor", req.Name)); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func saveProxy(w http.ResponseWriter, req *Proxy) {
if err := writeConfD("proxy", req.Name, ConfD{Proxies: []Proxy{*req}}); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err := systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func saveVisitor(w http.ResponseWriter, req *Visitor) {
if err := writeConfD("visitor", req.Name, ConfD{Visitors: []Visitor{*req}}); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err := systemctl.Restart("frpc"); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
+126
View File
@@ -0,0 +1,126 @@
package frp
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/pelletier/go-toml/v2"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/confval"
"github.com/acepanel/panel/v3/pkg/io"
)
// confPath frps.toml 或 frpc.toml 的路径
func confPath(name string) string {
return filepath.Join(app.Root, "server", "frp", name+".toml")
}
// confDDir 面板管理的代理与访问者所在目录
func confDDir() string {
return filepath.Join(app.Root, "server", "frp", "conf.d")
}
// confDGlob frpc.toml 里 includes 使用的匹配串
func confDGlob() string {
return filepath.Join(confDDir(), "*.toml")
}
// itemPath 一个代理或访问者对应的文件,分前缀避免同名代理与访问者撞文件
func itemPath(prefix, name string) string {
return filepath.Join(confDDir(), prefix+"-"+name+".toml")
}
// ensureConfD 保证 conf.d 目录存在且已被 frpc.toml 的 includes 引用
func ensureConfD() error {
if err := os.MkdirAll(confDDir(), 0755); err != nil {
return err
}
path := confPath("frpc")
config, err := io.Read(path)
if err != nil {
return err
}
includes, err := parseIncludes(confval.GetTOML(config, "includes"))
if err != nil {
return err
}
glob := confDGlob()
if slices.Contains(includes, glob) {
return nil
}
return io.Write(path, confval.SetTOML(config, "includes", append(includes, glob)), 0644)
}
// parseIncludes 解析 includes 数组的原始字面量
// 解析不了说明是跨行数组等改不动的写法,直接报错而不是覆盖掉用户的配置
func parseIncludes(raw string) ([]string, error) {
if raw == "" {
return nil, nil
}
var includes []string
if err := toml.Unmarshal([]byte("includes = "+raw), &struct {
Includes *[]string `toml:"includes"`
}{Includes: &includes}); err != nil {
return nil, fmt.Errorf("failed to parse includes in frpc.toml, please write it on a single line: %w", err)
}
return includes, nil
}
// listConfD 读出 conf.d 目录下的全部代理与访问者
func listConfD() (ConfD, error) {
var all ConfD
entries, err := os.ReadDir(confDDir())
if err != nil {
// 目录尚未创建时视为空
if os.IsNotExist(err) {
return all, nil
}
return all, err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".toml") {
continue
}
raw, err := os.ReadFile(filepath.Join(confDDir(), entry.Name()))
if err != nil {
return all, err
}
var confD ConfD
if err = toml.Unmarshal(raw, &confD); err != nil {
return all, err
}
all.Proxies = append(all.Proxies, confD.Proxies...)
all.Visitors = append(all.Visitors, confD.Visitors...)
}
return all, nil
}
// writeConfD 写入单个代理或访问者的配置文件
func writeConfD(prefix, name string, confD ConfD) error {
if err := ensureConfD(); err != nil {
return err
}
raw, err := toml.Marshal(confD)
if err != nil {
return err
}
return io.Write(itemPath(prefix, name), string(raw), 0644)
}
+5
View File
@@ -14,3 +14,8 @@ type UpdateUser struct {
User string `form:"user" json:"user" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""`
Group string `form:"group" json:"group" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""`
}
// ItemName 代理或访问者的名称,来自路径参数
type ItemName struct {
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
}
+133
View File
@@ -0,0 +1,133 @@
package frp
import (
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/apps/confval"
)
// tuneField 可视化参数与 TOML 键的对应关系,kind 决定写入时的字面量类型
type tuneField struct {
key string
value *string
kind byte // s 字符串、i 整数、b 布尔
}
// literal 把表单里的字符串还原成 TOML 字面量,空串表示注释掉该项
func (f tuneField) literal() any {
if *f.value == "" {
return ""
}
switch f.kind {
case 'i':
return cast.ToInt(*f.value)
case 'b':
return cast.ToBool(*f.value)
default:
return *f.value
}
}
// readTune 从配置内容读出各参数
func readTune(config string, fields []tuneField) {
for _, f := range fields {
*f.value = confval.GetTOML(config, f.key)
}
}
// writeTune 把各参数写回配置内容
func writeTune(config string, fields []tuneField) string {
for _, f := range fields {
config = confval.SetTOML(config, f.key, f.literal())
}
return config
}
func serverFields(t *ServerTune) []tuneField {
return []tuneField{
{"bindAddr", &t.BindAddr, 's'},
{"bindPort", &t.BindPort, 'i'},
{"kcpBindPort", &t.KCPBindPort, 'i'},
{"quicBindPort", &t.QUICBindPort, 'i'},
{"proxyBindAddr", &t.ProxyBindAddr, 's'},
{"subDomainHost", &t.SubDomainHost, 's'},
{"maxPortsPerClient", &t.MaxPortsPerClient, 'i'},
{"udpPacketSize", &t.UDPPacketSize, 'i'},
{"detailedErrorsToClient", &t.DetailedErrorsToClient, 'b'},
{"auth.method", &t.AuthMethod, 's'},
{"auth.token", &t.AuthToken, 's'},
{"vhostHTTPPort", &t.VhostHTTPPort, 'i'},
{"vhostHTTPSPort", &t.VhostHTTPSPort, 'i'},
{"vhostHTTPTimeout", &t.VhostHTTPTimeout, 'i'},
{"tcpmuxHTTPConnectPort", &t.TCPMuxHTTPConnectPort, 'i'},
{"custom404Page", &t.Custom404Page, 's'},
{"webServer.addr", &t.WebServerAddr, 's'},
{"webServer.port", &t.WebServerPort, 'i'},
{"webServer.user", &t.WebServerUser, 's'},
{"webServer.password", &t.WebServerPassword, 's'},
{"webServer.pprofEnable", &t.WebServerPprofEnable, 'b'},
{"enablePrometheus", &t.EnablePrometheus, 'b'},
{"transport.maxPoolCount", &t.TransportMaxPoolCount, 'i'},
{"transport.tcpMux", &t.TransportTCPMux, 'b'},
{"transport.tcpMuxKeepaliveInterval", &t.TransportTCPMuxKeepaliveInterval, 'i'},
{"transport.tcpKeepalive", &t.TransportTCPKeepalive, 'i'},
{"transport.heartbeatTimeout", &t.TransportHeartbeatTimeout, 'i'},
{"transport.tls.force", &t.TransportTLSForce, 'b'},
{"transport.tls.certFile", &t.TransportTLSCertFile, 's'},
{"transport.tls.keyFile", &t.TransportTLSKeyFile, 's'},
{"transport.tls.trustedCaFile", &t.TransportTLSTrustedCaFile, 's'},
{"log.to", &t.LogTo, 's'},
{"log.level", &t.LogLevel, 's'},
{"log.maxDays", &t.LogMaxDays, 'i'},
{"log.disablePrintColor", &t.LogDisablePrintColor, 'b'},
}
}
func clientFields(t *ClientTune) []tuneField {
return []tuneField{
{"user", &t.User, 's'},
{"serverAddr", &t.ServerAddr, 's'},
{"serverPort", &t.ServerPort, 'i'},
{"loginFailExit", &t.LoginFailExit, 'b'},
{"natHoleStunServer", &t.NatHoleStunServer, 's'},
{"dnsServer", &t.DNSServer, 's'},
{"udpPacketSize", &t.UDPPacketSize, 'i'},
{"auth.method", &t.AuthMethod, 's'},
{"auth.token", &t.AuthToken, 's'},
{"transport.protocol", &t.TransportProtocol, 's'},
{"transport.poolCount", &t.TransportPoolCount, 'i'},
{"transport.tcpMux", &t.TransportTCPMux, 'b'},
{"transport.tcpMuxKeepaliveInterval", &t.TransportTCPMuxKeepaliveInterval, 'i'},
{"transport.dialServerTimeout", &t.TransportDialServerTimeout, 'i'},
{"transport.dialServerKeepalive", &t.TransportDialServerKeepalive, 'i'},
{"transport.heartbeatInterval", &t.TransportHeartbeatInterval, 'i'},
{"transport.heartbeatTimeout", &t.TransportHeartbeatTimeout, 'i'},
{"transport.connectServerLocalIP", &t.TransportConnectServerLocalIP, 's'},
{"transport.proxyURL", &t.TransportProxyURL, 's'},
{"transport.tls.enable", &t.TransportTLSEnable, 'b'},
{"transport.tls.certFile", &t.TransportTLSCertFile, 's'},
{"transport.tls.keyFile", &t.TransportTLSKeyFile, 's'},
{"transport.tls.trustedCaFile", &t.TransportTLSTrustedCaFile, 's'},
{"transport.tls.serverName", &t.TransportTLSServerName, 's'},
{"webServer.addr", &t.WebServerAddr, 's'},
{"webServer.port", &t.WebServerPort, 'i'},
{"webServer.user", &t.WebServerUser, 's'},
{"webServer.password", &t.WebServerPassword, 's'},
{"webServer.pprofEnable", &t.WebServerPprofEnable, 'b'},
{"log.to", &t.LogTo, 's'},
{"log.level", &t.LogLevel, 's'},
{"log.maxDays", &t.LogMaxDays, 'i'},
{"log.disablePrintColor", &t.LogDisablePrintColor, 'b'},
}
}
+213
View File
@@ -14,3 +14,216 @@ type UserInfo struct {
User string `json:"user"`
Group string `json:"group"`
}
// ConfD conf.d 目录下的配置文件,只承载代理与访问者
type ConfD struct {
Proxies []Proxy `toml:"proxies,omitempty"`
Visitors []Visitor `toml:"visitors,omitempty"`
}
// Proxy 代理配置,字段对齐 frp pkg/config/v1 的 ProxyConfigurer 实现
type Proxy struct {
Name string `toml:"name" form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Type string `toml:"type" json:"type" validate:"required && in:tcp,udp,http,https,tcpmux,stcp,sudp,xtcp"`
Enabled *bool `toml:"enabled,omitempty" json:"enabled"`
LocalIP string `toml:"localIP,omitempty" json:"local_ip"`
LocalPort int `toml:"localPort,omitempty" json:"local_port"`
// tcp、udp
RemotePort int `toml:"remotePort,omitempty" json:"remote_port"`
// http、https、tcpmux
CustomDomains []string `toml:"customDomains,omitempty" json:"custom_domains"`
Subdomain string `toml:"subdomain,omitempty" json:"subdomain"`
// http、tcpmux
HTTPUser string `toml:"httpUser,omitempty" json:"http_user"`
HTTPPassword string `toml:"httpPassword,omitempty" json:"http_password"`
RouteByHTTPUser string `toml:"routeByHTTPUser,omitempty" json:"route_by_http_user"`
// http
Locations []string `toml:"locations,omitempty" json:"locations"`
HostHeaderRewrite string `toml:"hostHeaderRewrite,omitempty" json:"host_header_rewrite"`
RequestHeaders *HeaderOperations `toml:"requestHeaders,omitempty" json:"request_headers"`
ResponseHeaders *HeaderOperations `toml:"responseHeaders,omitempty" json:"response_headers"`
// tcpmux
Multiplexer string `toml:"multiplexer,omitempty" json:"multiplexer"`
// stcp、sudp、xtcp
SecretKey string `toml:"secretKey,omitempty" json:"secret_key"`
AllowUsers []string `toml:"allowUsers,omitempty" json:"allow_users"`
Transport *ProxyTransport `toml:"transport,omitempty" json:"transport"`
LoadBalancer *LoadBalancer `toml:"loadBalancer,omitempty" json:"load_balancer"`
HealthCheck *HealthCheck `toml:"healthCheck,omitempty" json:"health_check"`
Plugin *Plugin `toml:"plugin,omitempty" json:"plugin"`
Metadatas map[string]string `toml:"metadatas,omitempty" json:"metadatas"`
Annotations map[string]string `toml:"annotations,omitempty" json:"annotations"`
}
// Visitor 访问者配置,对应 stcp、sudp、xtcp 的客户端一侧
type Visitor struct {
Name string `toml:"name" form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_.-]+$\""`
Type string `toml:"type" json:"type" validate:"required && in:stcp,sudp,xtcp"`
Enabled *bool `toml:"enabled,omitempty" json:"enabled"`
ServerUser string `toml:"serverUser,omitempty" json:"server_user"`
ServerName string `toml:"serverName,omitempty" json:"server_name"`
SecretKey string `toml:"secretKey,omitempty" json:"secret_key"`
BindAddr string `toml:"bindAddr,omitempty" json:"bind_addr"`
BindPort int `toml:"bindPort,omitempty" json:"bind_port"`
// xtcp
Protocol string `toml:"protocol,omitempty" json:"protocol"`
KeepTunnelOpen bool `toml:"keepTunnelOpen,omitempty" json:"keep_tunnel_open"`
MaxRetriesAnHour int `toml:"maxRetriesAnHour,omitempty" json:"max_retries_an_hour"`
MinRetryInterval int `toml:"minRetryInterval,omitempty" json:"min_retry_interval"`
FallbackTo string `toml:"fallbackTo,omitempty" json:"fallback_to"`
FallbackTimeoutMs int `toml:"fallbackTimeoutMs,omitempty" json:"fallback_timeout_ms"`
Transport *VisitorTransport `toml:"transport,omitempty" json:"transport"`
}
type ProxyTransport struct {
UseEncryption bool `toml:"useEncryption,omitempty" json:"use_encryption"`
UseCompression bool `toml:"useCompression,omitempty" json:"use_compression"`
BandwidthLimit string `toml:"bandwidthLimit,omitempty" json:"bandwidth_limit"`
BandwidthLimitMode string `toml:"bandwidthLimitMode,omitempty" json:"bandwidth_limit_mode"`
ProxyProtocolVersion string `toml:"proxyProtocolVersion,omitempty" json:"proxy_protocol_version"`
}
type VisitorTransport struct {
UseEncryption bool `toml:"useEncryption,omitempty" json:"use_encryption"`
UseCompression bool `toml:"useCompression,omitempty" json:"use_compression"`
}
type LoadBalancer struct {
Group string `toml:"group,omitempty" json:"group"`
GroupKey string `toml:"groupKey,omitempty" json:"group_key"`
}
type HealthCheck struct {
Type string `toml:"type" json:"type"`
TimeoutSeconds int `toml:"timeoutSeconds,omitempty" json:"timeout_seconds"`
MaxFailed int `toml:"maxFailed,omitempty" json:"max_failed"`
IntervalSeconds int `toml:"intervalSeconds,omitempty" json:"interval_seconds"`
Path string `toml:"path,omitempty" json:"path"`
}
type HeaderOperations struct {
Set map[string]string `toml:"set,omitempty" json:"set"`
}
// Plugin 客户端插件配置,各插件字段的并集
type Plugin struct {
Type string `toml:"type" json:"type"`
// unix_domain_socket
UnixPath string `toml:"unixPath,omitempty" json:"unix_path"`
// http_proxy、static_file
HTTPUser string `toml:"httpUser,omitempty" json:"http_user"`
HTTPPassword string `toml:"httpPassword,omitempty" json:"http_password"`
// socks5
Username string `toml:"username,omitempty" json:"username"`
Password string `toml:"password,omitempty" json:"password"`
// static_file
LocalPath string `toml:"localPath,omitempty" json:"local_path"`
StripPrefix string `toml:"stripPrefix,omitempty" json:"strip_prefix"`
// https2http、https2https、http2https、http2http、tls2raw
LocalAddr string `toml:"localAddr,omitempty" json:"local_addr"`
HostHeaderRewrite string `toml:"hostHeaderRewrite,omitempty" json:"host_header_rewrite"`
CrtPath string `toml:"crtPath,omitempty" json:"crt_path"`
KeyPath string `toml:"keyPath,omitempty" json:"key_path"`
}
// ServerTune frps 可视化参数
type ServerTune struct {
BindAddr string `form:"bind_addr" json:"bind_addr"`
BindPort string `form:"bind_port" json:"bind_port"`
KCPBindPort string `form:"kcp_bind_port" json:"kcp_bind_port"`
QUICBindPort string `form:"quic_bind_port" json:"quic_bind_port"`
ProxyBindAddr string `form:"proxy_bind_addr" json:"proxy_bind_addr"`
SubDomainHost string `form:"sub_domain_host" json:"sub_domain_host"`
MaxPortsPerClient string `form:"max_ports_per_client" json:"max_ports_per_client"`
UDPPacketSize string `form:"udp_packet_size" json:"udp_packet_size"`
DetailedErrorsToClient string `form:"detailed_errors_to_client" json:"detailed_errors_to_client"`
AuthMethod string `form:"auth_method" json:"auth_method"`
AuthToken string `form:"auth_token" json:"auth_token"`
VhostHTTPPort string `form:"vhost_http_port" json:"vhost_http_port"`
VhostHTTPSPort string `form:"vhost_https_port" json:"vhost_https_port"`
VhostHTTPTimeout string `form:"vhost_http_timeout" json:"vhost_http_timeout"`
TCPMuxHTTPConnectPort string `form:"tcpmux_http_connect_port" json:"tcpmux_http_connect_port"`
Custom404Page string `form:"custom_404_page" json:"custom_404_page"`
WebServerAddr string `form:"web_server_addr" json:"web_server_addr"`
WebServerPort string `form:"web_server_port" json:"web_server_port"`
WebServerUser string `form:"web_server_user" json:"web_server_user"`
WebServerPassword string `form:"web_server_password" json:"web_server_password"`
WebServerPprofEnable string `form:"web_server_pprof_enable" json:"web_server_pprof_enable"`
EnablePrometheus string `form:"enable_prometheus" json:"enable_prometheus"`
TransportMaxPoolCount string `form:"transport_max_pool_count" json:"transport_max_pool_count"`
TransportTCPMux string `form:"transport_tcp_mux" json:"transport_tcp_mux"`
TransportTCPMuxKeepaliveInterval string `form:"transport_tcp_mux_keepalive_interval" json:"transport_tcp_mux_keepalive_interval"`
TransportTCPKeepalive string `form:"transport_tcp_keepalive" json:"transport_tcp_keepalive"`
TransportHeartbeatTimeout string `form:"transport_heartbeat_timeout" json:"transport_heartbeat_timeout"`
TransportTLSForce string `form:"transport_tls_force" json:"transport_tls_force"`
TransportTLSCertFile string `form:"transport_tls_cert_file" json:"transport_tls_cert_file"`
TransportTLSKeyFile string `form:"transport_tls_key_file" json:"transport_tls_key_file"`
TransportTLSTrustedCaFile string `form:"transport_tls_trusted_ca_file" json:"transport_tls_trusted_ca_file"`
LogTo string `form:"log_to" json:"log_to"`
LogLevel string `form:"log_level" json:"log_level"`
LogMaxDays string `form:"log_max_days" json:"log_max_days"`
LogDisablePrintColor string `form:"log_disable_print_color" json:"log_disable_print_color"`
}
// ClientTune frpc 可视化公共参数
type ClientTune struct {
User string `form:"user" json:"user"`
ServerAddr string `form:"server_addr" json:"server_addr"`
ServerPort string `form:"server_port" json:"server_port"`
LoginFailExit string `form:"login_fail_exit" json:"login_fail_exit"`
NatHoleStunServer string `form:"nat_hole_stun_server" json:"nat_hole_stun_server"`
DNSServer string `form:"dns_server" json:"dns_server"`
UDPPacketSize string `form:"udp_packet_size" json:"udp_packet_size"`
AuthMethod string `form:"auth_method" json:"auth_method"`
AuthToken string `form:"auth_token" json:"auth_token"`
TransportProtocol string `form:"transport_protocol" json:"transport_protocol"`
TransportPoolCount string `form:"transport_pool_count" json:"transport_pool_count"`
TransportTCPMux string `form:"transport_tcp_mux" json:"transport_tcp_mux"`
TransportTCPMuxKeepaliveInterval string `form:"transport_tcp_mux_keepalive_interval" json:"transport_tcp_mux_keepalive_interval"`
TransportDialServerTimeout string `form:"transport_dial_server_timeout" json:"transport_dial_server_timeout"`
TransportDialServerKeepalive string `form:"transport_dial_server_keepalive" json:"transport_dial_server_keepalive"`
TransportHeartbeatInterval string `form:"transport_heartbeat_interval" json:"transport_heartbeat_interval"`
TransportHeartbeatTimeout string `form:"transport_heartbeat_timeout" json:"transport_heartbeat_timeout"`
TransportConnectServerLocalIP string `form:"transport_connect_server_local_ip" json:"transport_connect_server_local_ip"`
TransportProxyURL string `form:"transport_proxy_url" json:"transport_proxy_url"`
TransportTLSEnable string `form:"transport_tls_enable" json:"transport_tls_enable"`
TransportTLSCertFile string `form:"transport_tls_cert_file" json:"transport_tls_cert_file"`
TransportTLSKeyFile string `form:"transport_tls_key_file" json:"transport_tls_key_file"`
TransportTLSTrustedCaFile string `form:"transport_tls_trusted_ca_file" json:"transport_tls_trusted_ca_file"`
TransportTLSServerName string `form:"transport_tls_server_name" json:"transport_tls_server_name"`
WebServerAddr string `form:"web_server_addr" json:"web_server_addr"`
WebServerPort string `form:"web_server_port" json:"web_server_port"`
WebServerUser string `form:"web_server_user" json:"web_server_user"`
WebServerPassword string `form:"web_server_password" json:"web_server_password"`
WebServerPprofEnable string `form:"web_server_pprof_enable" json:"web_server_pprof_enable"`
LogTo string `form:"log_to" json:"log_to"`
LogLevel string `form:"log_level" json:"log_level"`
LogMaxDays string `form:"log_max_days" json:"log_max_days"`
LogDisablePrintColor string `form:"log_disable_print_color" json:"log_disable_print_color"`
}
+27
View File
@@ -11,4 +11,31 @@ export default {
// 设置运行用户
saveUser: (name: string, user: string, group: string): any =>
http.Post('/apps/frp/user', { name, user, group }),
// 获取 frps 参数
server: (): any => http.Get('/apps/frp/server'),
// 保存 frps 参数
saveServer: (tune: any): any => http.Post('/apps/frp/server', tune),
// 获取 frpc 公共参数
client: (): any => http.Get('/apps/frp/client'),
// 保存 frpc 公共参数
saveClient: (tune: any): any => http.Post('/apps/frp/client', tune),
// 代理列表
proxies: (page: number, limit: number): any =>
http.Get('/apps/frp/proxies', { params: { page, limit } }),
// 添加代理
addProxy: (proxy: any): any => http.Post('/apps/frp/proxies', proxy),
// 更新代理
updateProxy: (name: string, proxy: any): any => http.Post(`/apps/frp/proxies/${name}`, proxy),
// 删除代理
deleteProxy: (name: string): any => http.Delete(`/apps/frp/proxies/${name}`),
// 访问者列表
visitors: (page: number, limit: number): any =>
http.Get('/apps/frp/visitors', { params: { page, limit } }),
// 添加访问者
addVisitor: (visitor: any): any => http.Post('/apps/frp/visitors', visitor),
// 更新访问者
updateVisitor: (name: string, visitor: any): any =>
http.Post(`/apps/frp/visitors/${name}`, visitor),
// 删除访问者
deleteVisitor: (name: string): any => http.Delete(`/apps/frp/visitors/${name}`),
}
+571
View File
@@ -0,0 +1,571 @@
<script setup lang="ts">
defineOptions({
name: 'frp-proxy-modal',
})
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
import KeyValueEditor from '@/components/common/KeyValueEditor.vue'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { required: true })
const props = defineProps<{
// 传入代理表示编辑,为空表示新增
proxy?: any
}>()
const emit = defineEmits<{ submitted: [] }>()
const submitLoading = ref(false)
const defaultModel = () => ({
name: '',
type: 'tcp',
enabled: true,
local_ip: '127.0.0.1',
local_port: null as number | null,
remote_port: null as number | null,
custom_domains: [] as string[],
subdomain: '',
http_user: '',
http_password: '',
route_by_http_user: '',
locations: [] as string[],
host_header_rewrite: '',
request_headers: {} as Record<string, string>,
response_headers: {} as Record<string, string>,
multiplexer: 'httpconnect',
secret_key: '',
allow_users: [] as string[],
transport: {
use_encryption: false,
use_compression: false,
bandwidth_limit: '',
bandwidth_limit_mode: '',
proxy_protocol_version: '',
},
load_balancer: { group: '', group_key: '' },
health_check: {
type: '',
timeout_seconds: null as number | null,
max_failed: null as number | null,
interval_seconds: null as number | null,
path: '',
},
plugin: {
type: '',
unix_path: '',
http_user: '',
http_password: '',
username: '',
password: '',
local_path: '',
strip_prefix: '',
local_addr: '',
host_header_rewrite: '',
crt_path: '',
key_path: '',
},
metadatas: {} as Record<string, string>,
annotations: {} as Record<string, string>,
})
const model = ref(defaultModel())
const isEdit = computed(() => !!props.proxy)
const type = computed(() => model.value.type)
// 使用插件时 localIP 与 localPort 会被忽略
const usePlugin = computed(() => !!model.value.plugin.type)
const typeOptions = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp'].map(
(item) => ({ label: item, value: item }),
)
const bandwidthModeOptions = [
{ label: $gettext('Client'), value: 'client' },
{ label: $gettext('Server'), value: 'server' },
]
const proxyProtocolOptions = [
{ label: 'v1', value: 'v1' },
{ label: 'v2', value: 'v2' },
]
const healthCheckOptions = [
{ label: 'tcp', value: 'tcp' },
{ label: 'http', value: 'http' },
]
const pluginOptions = [
'unix_domain_socket',
'http_proxy',
'socks5',
'static_file',
'https2http',
'https2https',
'http2https',
'http2http',
'tls2raw',
].map((item) => ({ label: item, value: item }))
// 插件类型到所需字段的映射
const pluginFields: Record<string, string[]> = {
unix_domain_socket: ['unix_path'],
http_proxy: ['http_user', 'http_password'],
socks5: ['username', 'password'],
static_file: ['local_path', 'strip_prefix', 'http_user', 'http_password'],
https2http: ['local_addr', 'host_header_rewrite', 'crt_path', 'key_path'],
https2https: ['local_addr', 'host_header_rewrite', 'crt_path', 'key_path'],
http2https: ['local_addr', 'host_header_rewrite'],
http2http: ['local_addr', 'host_header_rewrite'],
tls2raw: ['local_addr', 'crt_path', 'key_path'],
}
const hasPluginField = (field: string) => pluginFields[model.value.plugin.type]?.includes(field)
// 后端不返回的空值统一补成默认值,避免表单出现 undefined
const fill = (proxy: any) => {
const base = defaultModel()
return {
...base,
...proxy,
enabled: proxy.enabled !== false,
custom_domains: proxy.custom_domains ?? [],
locations: proxy.locations ?? [],
allow_users: proxy.allow_users ?? [],
request_headers: proxy.request_headers?.set ?? {},
response_headers: proxy.response_headers?.set ?? {},
transport: { ...base.transport, ...proxy.transport },
load_balancer: { ...base.load_balancer, ...proxy.load_balancer },
health_check: { ...base.health_check, ...proxy.health_check },
plugin: { ...base.plugin, ...proxy.plugin },
metadatas: proxy.metadatas ?? {},
annotations: proxy.annotations ?? {},
}
}
watch(show, (val) => {
if (val) {
model.value = props.proxy ? fill(props.proxy) : defaultModel()
}
})
// 判断嵌套对象是否有值,全为空时不提交,避免生成空的 TOML 子表
const filled = (obj: Record<string, any>) =>
Object.values(obj).some((value) => value !== '' && value !== false && value != null)
// 只提交当前类型用得上的字段,避免写出 frp 不认识的配置
const buildPayload = () => {
const m = model.value
const payload: any = {
name: m.name,
type: m.type,
enabled: m.enabled,
}
if (filled(m.transport)) {
payload.transport = m.transport
}
if (Object.keys(m.metadatas).length) {
payload.metadatas = m.metadatas
}
if (Object.keys(m.annotations).length) {
payload.annotations = m.annotations
}
if (!usePlugin.value) {
payload.local_ip = m.local_ip
payload.local_port = m.local_port ?? 0
} else {
payload.plugin = m.plugin
}
if (['tcp', 'udp'].includes(m.type)) {
payload.remote_port = m.remote_port ?? 0
}
if (['http', 'https', 'tcpmux'].includes(m.type)) {
payload.custom_domains = m.custom_domains
payload.subdomain = m.subdomain
}
if (['http', 'tcpmux'].includes(m.type)) {
payload.http_user = m.http_user
payload.http_password = m.http_password
payload.route_by_http_user = m.route_by_http_user
}
if (m.type === 'http') {
payload.locations = m.locations
payload.host_header_rewrite = m.host_header_rewrite
if (Object.keys(m.request_headers).length) {
payload.request_headers = { set: m.request_headers }
}
if (Object.keys(m.response_headers).length) {
payload.response_headers = { set: m.response_headers }
}
}
if (m.type === 'tcpmux') {
payload.multiplexer = m.multiplexer
}
if (['stcp', 'sudp', 'xtcp'].includes(m.type)) {
payload.secret_key = m.secret_key
payload.allow_users = m.allow_users
}
if (['tcp', 'http'].includes(m.type) && m.load_balancer.group) {
payload.load_balancer = m.load_balancer
}
if (m.health_check.type) {
payload.health_check = m.health_check
}
return payload
}
const handleSubmit = () => {
submitLoading.value = true
const payload = buildPayload()
const request = isEdit.value
? frp.updateProxy(payload.name, payload)
: frp.addProxy(payload)
useRequest(request)
.onSuccess(() => {
show.value = false
emit('submitted')
window.$message.success($gettext('Saved successfully'))
})
.onComplete(() => {
submitLoading.value = false
})
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="isEdit ? $gettext('Edit Proxy') : $gettext('Add Proxy')"
style="width: 70vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-form :model="model">
<n-form-item path="name" :label="$gettext('Name')">
<n-input
v-model:value="model.name"
:disabled="isEdit"
@keydown.enter.prevent
:placeholder="$gettext('Unique name, letters, digits, dot, underscore and hyphen only')"
/>
</n-form-item>
<n-form-item path="type" :label="$gettext('Type')">
<n-select v-model:value="model.type" :options="typeOptions" />
</n-form-item>
<n-form-item path="enabled" :label="$gettext('Enabled')">
<n-switch v-model:value="model.enabled" />
</n-form-item>
<template v-if="!usePlugin">
<n-form-item path="local_ip" :label="$gettext('Local Address (localIP)')">
<n-input v-model:value="model.local_ip" placeholder="127.0.0.1" @keydown.enter.prevent />
</n-form-item>
<n-form-item path="local_port" :label="$gettext('Local Port (localPort)')">
<n-input-number class="w-full" v-model:value="model.local_port" :min="1" :max="65535" />
</n-form-item>
</template>
<n-form-item
v-if="['tcp', 'udp'].includes(type)"
path="remote_port"
:label="$gettext('Remote Port (remotePort)')"
>
<n-input-number
class="w-full"
v-model:value="model.remote_port"
:min="0"
:max="65535"
:placeholder="$gettext('0 means frps assigns a random port')"
/>
</n-form-item>
<template v-if="['http', 'https', 'tcpmux'].includes(type)">
<n-form-item path="custom_domains" :label="$gettext('Custom Domains (customDomains)')">
<n-dynamic-input
v-model:value="model.custom_domains"
placeholder="web01.yourdomain.com"
show-sort-button
/>
</n-form-item>
<n-form-item path="subdomain" :label="$gettext('Subdomain (subdomain)')">
<n-input
v-model:value="model.subdomain"
@keydown.enter.prevent
:placeholder="$gettext('Requires subDomainHost on frps')"
/>
</n-form-item>
</template>
<template v-if="['http', 'tcpmux'].includes(type)">
<n-form-item path="http_user" :label="$gettext('HTTP Username (httpUser)')">
<n-input v-model:value="model.http_user" @keydown.enter.prevent />
</n-form-item>
<n-form-item path="http_password" :label="$gettext('HTTP Password (httpPassword)')">
<n-input v-model:value="model.http_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item path="route_by_http_user" :label="$gettext('Route By User (routeByHTTPUser)')">
<n-input v-model:value="model.route_by_http_user" @keydown.enter.prevent />
</n-form-item>
</template>
<template v-if="type === 'http'">
<n-form-item path="locations" :label="$gettext('Locations (locations)')">
<n-dynamic-input v-model:value="model.locations" placeholder="/" show-sort-button />
</n-form-item>
<n-form-item
path="host_header_rewrite"
:label="$gettext('Host Header Rewrite (hostHeaderRewrite)')"
>
<n-input
v-model:value="model.host_header_rewrite"
placeholder="example.com"
@keydown.enter.prevent
/>
</n-form-item>
<n-form-item path="request_headers" :label="$gettext('Request Headers (requestHeaders)')">
<key-value-editor
v-model="model.request_headers"
:key-placeholder="$gettext('Header')"
:value-placeholder="$gettext('Value')"
:add-button-text="$gettext('Add Header')"
default-key-prefix="x-from-where"
separator=":"
/>
</n-form-item>
<n-form-item path="response_headers" :label="$gettext('Response Headers (responseHeaders)')">
<key-value-editor
v-model="model.response_headers"
:key-placeholder="$gettext('Header')"
:value-placeholder="$gettext('Value')"
:add-button-text="$gettext('Add Header')"
default-key-prefix="x-from-where"
separator=":"
/>
</n-form-item>
</template>
<n-form-item
v-if="type === 'tcpmux'"
path="multiplexer"
:label="$gettext('Multiplexer (multiplexer)')"
>
<n-input v-model:value="model.multiplexer" placeholder="httpconnect" />
</n-form-item>
<template v-if="['stcp', 'sudp', 'xtcp'].includes(type)">
<n-form-item path="secret_key" :label="$gettext('Secret Key (secretKey)')">
<n-input
v-model:value="model.secret_key"
type="password"
show-password-on="click"
:placeholder="$gettext('Visitors must use the same secret key')"
/>
</n-form-item>
<n-form-item path="allow_users" :label="$gettext('Allowed Users (allowUsers)')">
<n-dynamic-input
v-model:value="model.allow_users"
:placeholder="$gettext('* means all users')"
show-sort-button
/>
</n-form-item>
</template>
<n-collapse>
<n-collapse-item :title="$gettext('Transport')" name="transport">
<n-form-item :label="$gettext('Encryption (transport.useEncryption)')">
<n-switch v-model:value="model.transport.use_encryption" />
</n-form-item>
<n-form-item :label="$gettext('Compression (transport.useCompression)')">
<n-switch v-model:value="model.transport.use_compression" />
</n-form-item>
<n-form-item :label="$gettext('Bandwidth Limit (transport.bandwidthLimit)')">
<n-input
v-model:value="model.transport.bandwidth_limit"
placeholder="1MB"
@keydown.enter.prevent
/>
</n-form-item>
<n-form-item :label="$gettext('Limit Side (transport.bandwidthLimitMode)')">
<n-select
v-model:value="model.transport.bandwidth_limit_mode"
:options="bandwidthModeOptions"
clearable
/>
</n-form-item>
<n-form-item
v-if="['tcp', 'https'].includes(type)"
:label="$gettext('Proxy Protocol (transport.proxyProtocolVersion)')"
>
<n-select
v-model:value="model.transport.proxy_protocol_version"
:options="proxyProtocolOptions"
clearable
/>
</n-form-item>
</n-collapse-item>
<n-collapse-item
v-if="['tcp', 'http'].includes(type)"
:title="$gettext('Load Balancer')"
name="load-balancer"
>
<n-form-item :label="$gettext('Group (loadBalancer.group)')">
<n-input v-model:value="model.load_balancer.group" @keydown.enter.prevent />
</n-form-item>
<n-form-item :label="$gettext('Group Key (loadBalancer.groupKey)')">
<n-input v-model:value="model.load_balancer.group_key" @keydown.enter.prevent />
</n-form-item>
</n-collapse-item>
<n-collapse-item :title="$gettext('Health Check')" name="health-check">
<n-form-item :label="$gettext('Type (healthCheck.type)')">
<n-select
v-model:value="model.health_check.type"
:options="healthCheckOptions"
clearable
/>
</n-form-item>
<template v-if="model.health_check.type">
<n-form-item :label="$gettext('Timeout Seconds (healthCheck.timeoutSeconds)')">
<n-input-number
class="w-full"
v-model:value="model.health_check.timeout_seconds"
:min="1"
:placeholder="$gettext('e.g. 3')"
/>
</n-form-item>
<n-form-item :label="$gettext('Max Failed (healthCheck.maxFailed)')">
<n-input-number
class="w-full"
v-model:value="model.health_check.max_failed"
:min="1"
:placeholder="$gettext('e.g. 3')"
/>
</n-form-item>
<n-form-item :label="$gettext('Interval Seconds (healthCheck.intervalSeconds)')">
<n-input-number
class="w-full"
v-model:value="model.health_check.interval_seconds"
:min="1"
:placeholder="$gettext('e.g. 10')"
/>
</n-form-item>
<n-form-item
v-if="model.health_check.type === 'http'"
:label="$gettext('Path (healthCheck.path)')"
>
<n-input
v-model:value="model.health_check.path"
placeholder="/status"
@keydown.enter.prevent
/>
</n-form-item>
</template>
</n-collapse-item>
<n-collapse-item :title="$gettext('Plugin')" name="plugin">
<n-alert type="info" mb-16>
{{ $gettext('When a plugin is used, the local address and port are ignored.') }}
</n-alert>
<n-form-item :label="$gettext('Type (plugin.type)')">
<n-select v-model:value="model.plugin.type" :options="pluginOptions" clearable />
</n-form-item>
<n-form-item v-if="hasPluginField('unix_path')" :label="$gettext('Socket Path (unixPath)')">
<n-input
v-model:value="model.plugin.unix_path"
placeholder="/var/run/docker.sock"
@keydown.enter.prevent
/>
</n-form-item>
<n-form-item v-if="hasPluginField('local_addr')" :label="$gettext('Local Address (localAddr)')">
<n-input
v-model:value="model.plugin.local_addr"
placeholder="127.0.0.1:80"
@keydown.enter.prevent
/>
</n-form-item>
<n-form-item v-if="hasPluginField('local_path')" :label="$gettext('Local Path (localPath)')">
<n-input
v-model:value="model.plugin.local_path"
placeholder="/var/www/blog"
@keydown.enter.prevent
/>
</n-form-item>
<n-form-item
v-if="hasPluginField('strip_prefix')"
:label="$gettext('Strip Prefix (stripPrefix)')"
>
<n-input v-model:value="model.plugin.strip_prefix" @keydown.enter.prevent />
</n-form-item>
<n-form-item
v-if="hasPluginField('host_header_rewrite')"
:label="$gettext('Host Header Rewrite (hostHeaderRewrite)')"
>
<n-input v-model:value="model.plugin.host_header_rewrite" @keydown.enter.prevent />
</n-form-item>
<n-form-item v-if="hasPluginField('crt_path')" :label="$gettext('Certificate (crtPath)')">
<n-input v-model:value="model.plugin.crt_path" @keydown.enter.prevent />
</n-form-item>
<n-form-item v-if="hasPluginField('key_path')" :label="$gettext('Private Key (keyPath)')">
<n-input v-model:value="model.plugin.key_path" @keydown.enter.prevent />
</n-form-item>
<n-form-item v-if="hasPluginField('http_user')" :label="$gettext('Username (httpUser)')">
<n-input v-model:value="model.plugin.http_user" @keydown.enter.prevent />
</n-form-item>
<n-form-item
v-if="hasPluginField('http_password')"
:label="$gettext('Password (httpPassword)')"
>
<n-input
v-model:value="model.plugin.http_password"
type="password"
show-password-on="click"
/>
</n-form-item>
<n-form-item v-if="hasPluginField('username')" :label="$gettext('Username (username)')">
<n-input v-model:value="model.plugin.username" @keydown.enter.prevent />
</n-form-item>
<n-form-item v-if="hasPluginField('password')" :label="$gettext('Password (password)')">
<n-input
v-model:value="model.plugin.password"
type="password"
show-password-on="click"
/>
</n-form-item>
</n-collapse-item>
<n-collapse-item :title="$gettext('Metadata')" name="metadata">
<n-form-item :label="$gettext('Metadatas (metadatas)')">
<key-value-editor
v-model="model.metadatas"
:add-button-text="$gettext('Add Metadata')"
default-key-prefix="var"
/>
</n-form-item>
<n-form-item :label="$gettext('Annotations (annotations)')">
<key-value-editor
v-model="model.annotations"
:add-button-text="$gettext('Add Annotation')"
default-key-prefix="key"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<n-button
type="info"
mt-16 block
:loading="submitLoading"
:disabled="submitLoading"
@click="handleSubmit"
>
{{ $gettext('Submit') }}
</n-button>
</n-modal>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
defineOptions({
name: 'frp-proxy-view',
})
import { NButton, NFlex, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
import { useConfirm } from '@/components/system/composables/useConfirm'
import FrpProxyModal from './FrpProxyModal.vue'
const { $gettext } = useGettext()
const { confirmDelete } = useConfirm()
const modalShow = ref(false)
const current = ref<any>(undefined)
const columns: any = [
{
title: $gettext('Name'),
key: 'name',
minWidth: 150,
resizable: true,
ellipsis: { tooltip: true },
},
{
title: $gettext('Type'),
key: 'type',
width: 100,
render(row: any) {
return h(NTag, { type: 'info', size: 'small' }, { default: () => row.type })
},
},
{
title: $gettext('Status'),
key: 'enabled',
width: 100,
render(row: any) {
return row.enabled === false
? h(NTag, { type: 'warning', size: 'small' }, { default: () => $gettext('Disabled') })
: h(NTag, { type: 'success', size: 'small' }, { default: () => $gettext('Enabled') })
},
},
{
title: $gettext('Local'),
key: 'local',
minWidth: 180,
ellipsis: { tooltip: true },
render(row: any) {
if (row.plugin?.type) {
return $gettext('Plugin: %{ plugin }', { plugin: row.plugin.type })
}
return `${row.local_ip || '127.0.0.1'}:${row.local_port || '-'}`
},
},
{
title: $gettext('Remote'),
key: 'remote',
minWidth: 180,
ellipsis: { tooltip: true },
render(row: any) {
if (['tcp', 'udp'].includes(row.type)) {
return row.remote_port ? String(row.remote_port) : $gettext('Random')
}
if (row.subdomain) return row.subdomain
if (row.custom_domains?.length) return row.custom_domains.join(', ')
return '-'
},
},
{
title: $gettext('Actions'),
key: 'actions',
width: 200,
hideInExcel: true,
render(row: any) {
return h(NFlex, { size: 'small', align: 'center' }, () => [
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => {
current.value = row
modalShow.value = true
},
},
{ default: () => $gettext('Configure') },
),
h(
NButton,
{
size: 'small',
type: 'error',
onClick: async () => {
const ok = await confirmDelete({
content: $gettext('Are you sure you want to delete proxy %{ name }?', {
name: row.name,
}),
})
if (ok) handleDelete(row.name)
},
},
{ default: () => $gettext('Delete') },
),
])
},
},
]
const { loading, data, page, total, pageSize, refresh } = usePagination(
(page, pageSize) => frp.proxies(page, pageSize),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items,
},
)
const handleDelete = (name: string) => {
useRequest(frp.deleteProxy(name)).onSuccess(() => {
refresh()
window.$message.success($gettext('Deleted successfully'))
})
}
const handleAdd = () => {
current.value = undefined
modalShow.value = true
}
onMounted(() => {
refresh()
})
</script>
<template>
<n-flex vertical>
<n-alert type="info">
{{
$gettext(
'Only proxies created by the panel under the conf.d directory are managed here. Proxies written directly in frpc.toml should be edited in the main configuration.',
)
}}
</n-alert>
<n-flex>
<n-button type="primary" @click="handleAdd">
{{ $gettext('Add Proxy') }}
</n-button>
</n-flex>
<n-data-table
v-model:page="page"
v-model:pageSize="pageSize"
striped
remote
:scroll-x="1000"
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: any) => row.name"
:pagination="{
page: page,
pageSize: pageSize,
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100, 200],
}"
/>
</n-flex>
<frp-proxy-modal v-model:show="modalShow" :proxy="current" @submitted="refresh" />
</template>
+246
View File
@@ -0,0 +1,246 @@
<script setup lang="ts">
defineOptions({
name: 'frp-visitor-modal',
})
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { required: true })
const props = defineProps<{
// 传入访问者表示编辑,为空表示新增
visitor?: any
}>()
const emit = defineEmits<{ submitted: [] }>()
const submitLoading = ref(false)
const defaultModel = () => ({
name: '',
type: 'stcp',
enabled: true,
server_user: '',
server_name: '',
secret_key: '',
bind_addr: '127.0.0.1',
bind_port: null as number | null,
protocol: '',
keep_tunnel_open: false,
max_retries_an_hour: null as number | null,
min_retry_interval: null as number | null,
fallback_to: '',
fallback_timeout_ms: null as number | null,
transport: { use_encryption: false, use_compression: false },
})
const model = ref(defaultModel())
const isEdit = computed(() => !!props.visitor)
const isXTCP = computed(() => model.value.type === 'xtcp')
const typeOptions = ['stcp', 'sudp', 'xtcp'].map((item) => ({ label: item, value: item }))
const protocolOptions = ['quic', 'kcp'].map((item) => ({ label: item, value: item }))
const fill = (visitor: any) => {
const base = defaultModel()
return {
...base,
...visitor,
enabled: visitor.enabled !== false,
transport: { ...base.transport, ...visitor.transport },
}
}
watch(show, (val) => {
if (val) {
model.value = props.visitor ? fill(props.visitor) : defaultModel()
}
})
// 只提交当前类型用得上的字段,避免写出 frp 不认识的配置
const buildPayload = () => {
const m = model.value
const payload: any = {
name: m.name,
type: m.type,
enabled: m.enabled,
server_user: m.server_user,
server_name: m.server_name,
secret_key: m.secret_key,
bind_addr: m.bind_addr,
bind_port: m.bind_port ?? 0,
}
if (m.transport.use_encryption || m.transport.use_compression) {
payload.transport = m.transport
}
if (isXTCP.value) {
payload.protocol = m.protocol
payload.keep_tunnel_open = m.keep_tunnel_open
payload.max_retries_an_hour = m.max_retries_an_hour ?? 0
payload.min_retry_interval = m.min_retry_interval ?? 0
payload.fallback_to = m.fallback_to
payload.fallback_timeout_ms = m.fallback_timeout_ms ?? 0
}
return payload
}
const handleSubmit = () => {
submitLoading.value = true
const payload = buildPayload()
const request = isEdit.value
? frp.updateVisitor(payload.name, payload)
: frp.addVisitor(payload)
useRequest(request)
.onSuccess(() => {
show.value = false
emit('submitted')
window.$message.success($gettext('Saved successfully'))
})
.onComplete(() => {
submitLoading.value = false
})
}
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="isEdit ? $gettext('Edit Visitor') : $gettext('Add Visitor')"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-form :model="model">
<n-form-item path="name" :label="$gettext('Name')">
<n-input
v-model:value="model.name"
:disabled="isEdit"
@keydown.enter.prevent
:placeholder="$gettext('Unique name, letters, digits, dot, underscore and hyphen only')"
/>
</n-form-item>
<n-form-item path="type" :label="$gettext('Type')">
<n-select v-model:value="model.type" :options="typeOptions" />
</n-form-item>
<n-form-item path="enabled" :label="$gettext('Enabled')">
<n-switch v-model:value="model.enabled" />
</n-form-item>
<n-form-item path="server_name" :label="$gettext('Target Proxy (serverName)')">
<n-input
v-model:value="model.server_name"
@keydown.enter.prevent
:placeholder="$gettext('Name of the proxy to visit')"
/>
</n-form-item>
<n-form-item path="server_user" :label="$gettext('Target User (serverUser)')">
<n-input
v-model:value="model.server_user"
@keydown.enter.prevent
:placeholder="$gettext('Defaults to the current user')"
/>
</n-form-item>
<n-form-item path="secret_key" :label="$gettext('Secret Key (secretKey)')">
<n-input
v-model:value="model.secret_key"
type="password"
show-password-on="click"
:placeholder="$gettext('Must match the target proxy')"
/>
</n-form-item>
<n-form-item path="bind_addr" :label="$gettext('Bind Address (bindAddr)')">
<n-input v-model:value="model.bind_addr" placeholder="127.0.0.1" @keydown.enter.prevent />
</n-form-item>
<n-form-item path="bind_port" :label="$gettext('Bind Port (bindPort)')">
<n-input-number
class="w-full"
v-model:value="model.bind_port"
:min="-1"
:max="65535"
:placeholder="$gettext('-1 means do not bind, only accept redirected connections')"
/>
</n-form-item>
<template v-if="isXTCP">
<n-form-item path="protocol" :label="$gettext('Protocol (protocol)')">
<n-select v-model:value="model.protocol" :options="protocolOptions" clearable />
</n-form-item>
<n-form-item path="keep_tunnel_open" :label="$gettext('Keep Tunnel Open (keepTunnelOpen)')">
<n-switch v-model:value="model.keep_tunnel_open" />
</n-form-item>
<template v-if="model.keep_tunnel_open">
<n-form-item
path="max_retries_an_hour"
:label="$gettext('Max Retries Per Hour (maxRetriesAnHour)')"
>
<n-input-number
class="w-full"
v-model:value="model.max_retries_an_hour"
:min="1"
:placeholder="$gettext('e.g. 8')"
/>
</n-form-item>
<n-form-item
path="min_retry_interval"
:label="$gettext('Min Retry Interval (minRetryInterval)')"
>
<n-input-number
class="w-full"
v-model:value="model.min_retry_interval"
:min="1"
:placeholder="$gettext('e.g. 90')"
/>
</n-form-item>
</template>
<n-form-item path="fallback_to" :label="$gettext('Fallback To (fallbackTo)')">
<n-input
v-model:value="model.fallback_to"
@keydown.enter.prevent
:placeholder="$gettext('Name of the visitor to fall back to')"
/>
</n-form-item>
<n-form-item
path="fallback_timeout_ms"
:label="$gettext('Fallback Timeout (fallbackTimeoutMs)')"
>
<n-input-number
class="w-full"
v-model:value="model.fallback_timeout_ms"
:min="1"
:placeholder="$gettext('e.g. 500')"
/>
</n-form-item>
</template>
<n-collapse>
<n-collapse-item :title="$gettext('Transport')" name="transport">
<n-form-item :label="$gettext('Encryption (transport.useEncryption)')">
<n-switch v-model:value="model.transport.use_encryption" />
</n-form-item>
<n-form-item :label="$gettext('Compression (transport.useCompression)')">
<n-switch v-model:value="model.transport.use_compression" />
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<n-button
type="info"
mt-16 block
:loading="submitLoading"
:disabled="submitLoading"
@click="handleSubmit"
>
{{ $gettext('Submit') }}
</n-button>
</n-modal>
</template>
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
defineOptions({
name: 'frp-visitor-view',
})
import { NButton, NFlex, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
import { useConfirm } from '@/components/system/composables/useConfirm'
import FrpVisitorModal from './FrpVisitorModal.vue'
const { $gettext } = useGettext()
const { confirmDelete } = useConfirm()
const modalShow = ref(false)
const current = ref<any>(undefined)
const columns: any = [
{
title: $gettext('Name'),
key: 'name',
minWidth: 150,
resizable: true,
ellipsis: { tooltip: true },
},
{
title: $gettext('Type'),
key: 'type',
width: 100,
render(row: any) {
return h(NTag, { type: 'info', size: 'small' }, { default: () => row.type })
},
},
{
title: $gettext('Status'),
key: 'enabled',
width: 100,
render(row: any) {
return row.enabled === false
? h(NTag, { type: 'warning', size: 'small' }, { default: () => $gettext('Disabled') })
: h(NTag, { type: 'success', size: 'small' }, { default: () => $gettext('Enabled') })
},
},
{
title: $gettext('Target Proxy'),
key: 'server_name',
minWidth: 150,
ellipsis: { tooltip: true },
},
{
title: $gettext('Local Listen'),
key: 'bind',
minWidth: 180,
ellipsis: { tooltip: true },
render(row: any) {
return `${row.bind_addr || '127.0.0.1'}:${row.bind_port || '-'}`
},
},
{
title: $gettext('Actions'),
key: 'actions',
width: 200,
hideInExcel: true,
render(row: any) {
return h(NFlex, { size: 'small', align: 'center' }, () => [
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => {
current.value = row
modalShow.value = true
},
},
{ default: () => $gettext('Configure') },
),
h(
NButton,
{
size: 'small',
type: 'error',
onClick: async () => {
const ok = await confirmDelete({
content: $gettext('Are you sure you want to delete visitor %{ name }?', {
name: row.name,
}),
})
if (ok) handleDelete(row.name)
},
},
{ default: () => $gettext('Delete') },
),
])
},
},
]
const { loading, data, page, total, pageSize, refresh } = usePagination(
(page, pageSize) => frp.visitors(page, pageSize),
{
initialData: { total: 0, list: [] },
initialPageSize: 20,
total: (res: any) => res.total,
data: (res: any) => res.items,
},
)
const handleDelete = (name: string) => {
useRequest(frp.deleteVisitor(name)).onSuccess(() => {
refresh()
window.$message.success($gettext('Deleted successfully'))
})
}
const handleAdd = () => {
current.value = undefined
modalShow.value = true
}
onMounted(() => {
refresh()
})
</script>
<template>
<n-flex vertical>
<n-alert type="info">
{{
$gettext(
'Visitors are used to access stcp, sudp and xtcp proxies. They must be configured on the client that initiates the access.',
)
}}
</n-alert>
<n-flex>
<n-button type="primary" @click="handleAdd">
{{ $gettext('Add Visitor') }}
</n-button>
</n-flex>
<n-data-table
v-model:page="page"
v-model:pageSize="pageSize"
striped
remote
:scroll-x="1000"
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: any) => row.name"
:pagination="{
page: page,
pageSize: pageSize,
itemCount: total,
showQuickJumper: true,
showSizePicker: true,
pageSizes: [20, 50, 100, 200],
}"
/>
</n-flex>
<frp-visitor-modal v-model:show="modalShow" :visitor="current" @submitted="refresh" />
</template>
@@ -0,0 +1,308 @@
<script setup lang="ts">
defineOptions({
name: 'frpc-config-tune',
})
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
const { $gettext } = useGettext()
const currentTab = ref('general')
const saveLoading = ref(false)
const model = ref<Record<string, string>>({
user: '',
server_addr: '',
server_port: '',
login_fail_exit: '',
nat_hole_stun_server: '',
dns_server: '',
udp_packet_size: '',
auth_method: '',
auth_token: '',
transport_protocol: '',
transport_pool_count: '',
transport_tcp_mux: '',
transport_tcp_mux_keepalive_interval: '',
transport_dial_server_timeout: '',
transport_dial_server_keepalive: '',
transport_heartbeat_interval: '',
transport_heartbeat_timeout: '',
transport_connect_server_local_ip: '',
transport_proxy_url: '',
transport_tls_enable: '',
transport_tls_cert_file: '',
transport_tls_key_file: '',
transport_tls_trusted_ca_file: '',
transport_tls_server_name: '',
web_server_addr: '',
web_server_port: '',
web_server_user: '',
web_server_password: '',
web_server_pprof_enable: '',
log_to: '',
log_level: '',
log_max_days: '',
log_disable_print_color: '',
})
const boolOptions = [
{ label: $gettext('Enabled'), value: 'true' },
{ label: $gettext('Disabled'), value: 'false' },
]
const authMethodOptions = [
{ label: 'token', value: 'token' },
{ label: 'oidc', value: 'oidc' },
]
const protocolOptions = ['tcp', 'kcp', 'quic', 'websocket', 'wss'].map((item) => ({
label: item,
value: item,
}))
const logLevelOptions = ['trace', 'debug', 'info', 'warn', 'error'].map((item) => ({
label: item,
value: item,
}))
useRequest(frp.client()).onSuccess(({ data }: any) => {
model.value = { ...model.value, ...data }
})
const handleSave = () => {
saveLoading.value = true
useRequest(frp.saveClient(model.value))
.onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
})
.onComplete(() => {
saveLoading.value = false
})
}
</script>
<template>
<n-tabs v-model:value="currentTab" type="line" placement="left" animated>
<n-tab-pane name="general" :tab="$gettext('General')">
<n-flex vertical>
<n-alert type="info">
{{ $gettext('Leave a field empty to comment out the option and use the frpc default.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Server Address (serverAddr)')">
<n-input v-model:value="model.server_addr" placeholder="0.0.0.0" />
</n-form-item>
<n-form-item :label="$gettext('Server Port (serverPort)')">
<n-input v-model:value="model.server_port" placeholder="7000" />
</n-form-item>
<n-form-item :label="$gettext('User (user)')">
<n-input
v-model:value="model.user"
:placeholder="$gettext('Proxy names will be prefixed with this user')"
/>
</n-form-item>
<n-form-item :label="$gettext('Exit On Login Failure (loginFailExit)')">
<n-select v-model:value="model.login_fail_exit" :options="boolOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('STUN Server (natHoleStunServer)')">
<n-input
v-model:value="model.nat_hole_stun_server"
placeholder="stun.easyvoip.com:3478"
/>
</n-form-item>
<n-form-item :label="$gettext('DNS Server (dnsServer)')">
<n-input v-model:value="model.dns_server" placeholder="8.8.8.8" />
</n-form-item>
<n-form-item :label="$gettext('UDP Packet Size (udpPacketSize)')">
<n-input v-model:value="model.udp_packet_size" placeholder="1500" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="auth" :tab="$gettext('Authentication')">
<n-flex vertical>
<n-alert type="warning">
{{ $gettext('The token must be identical on frps and frpc, otherwise login will fail.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Method (auth.method)')">
<n-select v-model:value="model.auth_method" :options="authMethodOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('Token (auth.token)')">
<n-input
v-model:value="model.auth_token"
type="password"
show-password-on="click"
:placeholder="$gettext('Leave empty for no authentication')"
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="transport" :tab="$gettext('Transport')">
<n-flex vertical>
<n-alert type="warning">
{{ $gettext('Do not modify these options unless you know what they do.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Protocol (transport.protocol)')">
<n-select v-model:value="model.transport_protocol" :options="protocolOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('Pool Count (transport.poolCount)')">
<n-input v-model:value="model.transport_pool_count" placeholder="5" />
</n-form-item>
<n-form-item :label="$gettext('TCP Mux (transport.tcpMux)')">
<n-select v-model:value="model.transport_tcp_mux" :options="boolOptions" clearable />
</n-form-item>
<n-form-item
:label="$gettext('TCP Mux Keepalive Interval (transport.tcpMuxKeepaliveInterval)')"
>
<n-input v-model:value="model.transport_tcp_mux_keepalive_interval" placeholder="30" />
</n-form-item>
<n-form-item :label="$gettext('Dial Timeout (transport.dialServerTimeout)')">
<n-input v-model:value="model.transport_dial_server_timeout" placeholder="10" />
</n-form-item>
<n-form-item :label="$gettext('Dial Keepalive (transport.dialServerKeepalive)')">
<n-input v-model:value="model.transport_dial_server_keepalive" placeholder="7200" />
</n-form-item>
<n-form-item :label="$gettext('Heartbeat Interval (transport.heartbeatInterval)')">
<n-input v-model:value="model.transport_heartbeat_interval" placeholder="30" />
</n-form-item>
<n-form-item :label="$gettext('Heartbeat Timeout (transport.heartbeatTimeout)')">
<n-input v-model:value="model.transport_heartbeat_timeout" placeholder="90" />
</n-form-item>
<n-form-item :label="$gettext('Local IP (transport.connectServerLocalIP)')">
<n-input v-model:value="model.transport_connect_server_local_ip" placeholder="0.0.0.0" />
</n-form-item>
<n-form-item :label="$gettext('Proxy URL (transport.proxyURL)')">
<n-input
v-model:value="model.transport_proxy_url"
placeholder="socks5://user:passwd@192.168.1.128:1080"
/>
</n-form-item>
<n-form-item :label="$gettext('Enable TLS (transport.tls.enable)')">
<n-select v-model:value="model.transport_tls_enable" :options="boolOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('TLS Certificate (transport.tls.certFile)')">
<n-input v-model:value="model.transport_tls_cert_file" placeholder="client.crt" />
</n-form-item>
<n-form-item :label="$gettext('TLS Private Key (transport.tls.keyFile)')">
<n-input v-model:value="model.transport_tls_key_file" placeholder="client.key" />
</n-form-item>
<n-form-item :label="$gettext('TLS CA (transport.tls.trustedCaFile)')">
<n-input v-model:value="model.transport_tls_trusted_ca_file" placeholder="ca.crt" />
</n-form-item>
<n-form-item :label="$gettext('TLS Server Name (transport.tls.serverName)')">
<n-input v-model:value="model.transport_tls_server_name" placeholder="example.com" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="admin" :tab="$gettext('Admin API')">
<n-flex vertical>
<n-alert type="info">
{{ $gettext('The frpc admin API is available only when the port is set.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Listen Address (webServer.addr)')">
<n-input v-model:value="model.web_server_addr" placeholder="127.0.0.1" />
</n-form-item>
<n-form-item :label="$gettext('Listen Port (webServer.port)')">
<n-input v-model:value="model.web_server_port" placeholder="7400" />
</n-form-item>
<n-form-item :label="$gettext('Username (webServer.user)')">
<n-input v-model:value="model.web_server_user" placeholder="admin" />
</n-form-item>
<n-form-item :label="$gettext('Password (webServer.password)')">
<n-input
v-model:value="model.web_server_password"
type="password"
show-password-on="click"
/>
</n-form-item>
<n-form-item :label="$gettext('Enable pprof (webServer.pprofEnable)')">
<n-select
v-model:value="model.web_server_pprof_enable"
:options="boolOptions"
clearable
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="log" :tab="$gettext('Logs')">
<n-flex vertical>
<n-form>
<n-form-item :label="$gettext('Output (log.to)')">
<n-input v-model:value="model.log_to" placeholder="./frpc.log" />
</n-form-item>
<n-form-item :label="$gettext('Level (log.level)')">
<n-select v-model:value="model.log_level" :options="logLevelOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('Max Days (log.maxDays)')">
<n-input v-model:value="model.log_max_days" placeholder="3" />
</n-form-item>
<n-form-item :label="$gettext('Disable Colors (log.disablePrintColor)')">
<n-select
v-model:value="model.log_disable_print_color"
:options="boolOptions"
clearable
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
@@ -0,0 +1,340 @@
<script setup lang="ts">
defineOptions({
name: 'frps-config-tune',
})
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
const { $gettext } = useGettext()
const currentTab = ref('general')
const saveLoading = ref(false)
const model = ref<Record<string, string>>({
bind_addr: '',
bind_port: '',
kcp_bind_port: '',
quic_bind_port: '',
proxy_bind_addr: '',
sub_domain_host: '',
max_ports_per_client: '',
udp_packet_size: '',
detailed_errors_to_client: '',
auth_method: '',
auth_token: '',
vhost_http_port: '',
vhost_https_port: '',
vhost_http_timeout: '',
tcpmux_http_connect_port: '',
custom_404_page: '',
web_server_addr: '',
web_server_port: '',
web_server_user: '',
web_server_password: '',
web_server_pprof_enable: '',
enable_prometheus: '',
transport_max_pool_count: '',
transport_tcp_mux: '',
transport_tcp_mux_keepalive_interval: '',
transport_tcp_keepalive: '',
transport_heartbeat_timeout: '',
transport_tls_force: '',
transport_tls_cert_file: '',
transport_tls_key_file: '',
transport_tls_trusted_ca_file: '',
log_to: '',
log_level: '',
log_max_days: '',
log_disable_print_color: '',
})
const boolOptions = [
{ label: $gettext('Enabled'), value: 'true' },
{ label: $gettext('Disabled'), value: 'false' },
]
const authMethodOptions = [
{ label: 'token', value: 'token' },
{ label: 'oidc', value: 'oidc' },
]
const logLevelOptions = ['trace', 'debug', 'info', 'warn', 'error'].map((item) => ({
label: item,
value: item,
}))
useRequest(frp.server()).onSuccess(({ data }: any) => {
model.value = { ...model.value, ...data }
})
const handleSave = () => {
saveLoading.value = true
useRequest(frp.saveServer(model.value))
.onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
})
.onComplete(() => {
saveLoading.value = false
})
}
</script>
<template>
<n-tabs v-model:value="currentTab" type="line" placement="left" animated>
<n-tab-pane name="general" :tab="$gettext('General')">
<n-flex vertical>
<n-alert type="info">
{{ $gettext('Leave a field empty to comment out the option and use the frps default.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Bind Address (bindAddr)')">
<n-input v-model:value="model.bind_addr" placeholder="0.0.0.0" />
</n-form-item>
<n-form-item :label="$gettext('Bind Port (bindPort)')">
<n-input v-model:value="model.bind_port" placeholder="7000" />
</n-form-item>
<n-form-item :label="$gettext('KCP Bind Port (kcpBindPort)')">
<n-input
v-model:value="model.kcp_bind_port"
:placeholder="$gettext('Leave empty to disable KCP')"
/>
</n-form-item>
<n-form-item :label="$gettext('QUIC Bind Port (quicBindPort)')">
<n-input
v-model:value="model.quic_bind_port"
:placeholder="$gettext('Leave empty to disable QUIC')"
/>
</n-form-item>
<n-form-item :label="$gettext('Proxy Bind Address (proxyBindAddr)')">
<n-input
v-model:value="model.proxy_bind_addr"
:placeholder="$gettext('Defaults to bindAddr')"
/>
</n-form-item>
<n-form-item :label="$gettext('Subdomain Host (subDomainHost)')">
<n-input v-model:value="model.sub_domain_host" placeholder="frps.com" />
</n-form-item>
<n-form-item :label="$gettext('Max Ports Per Client (maxPortsPerClient)')">
<n-input
v-model:value="model.max_ports_per_client"
:placeholder="$gettext('0 means no limit')"
/>
</n-form-item>
<n-form-item :label="$gettext('UDP Packet Size (udpPacketSize)')">
<n-input v-model:value="model.udp_packet_size" placeholder="1500" />
</n-form-item>
<n-form-item :label="$gettext('Detailed Errors To Client (detailedErrorsToClient)')">
<n-select
v-model:value="model.detailed_errors_to_client"
:options="boolOptions"
clearable
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="auth" :tab="$gettext('Authentication')">
<n-flex vertical>
<n-alert type="warning">
{{ $gettext('The token must be identical on frps and frpc, otherwise login will fail.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Method (auth.method)')">
<n-select v-model:value="model.auth_method" :options="authMethodOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('Token (auth.token)')">
<n-input
v-model:value="model.auth_token"
type="password"
show-password-on="click"
:placeholder="$gettext('Leave empty for no authentication')"
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="vhost" :tab="$gettext('HTTP(S)')">
<n-flex vertical>
<n-alert type="info">
{{ $gettext('Required for http and https type proxies.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('HTTP Port (vhostHTTPPort)')">
<n-input v-model:value="model.vhost_http_port" placeholder="80" />
</n-form-item>
<n-form-item :label="$gettext('HTTPS Port (vhostHTTPSPort)')">
<n-input v-model:value="model.vhost_https_port" placeholder="443" />
</n-form-item>
<n-form-item :label="$gettext('HTTP Response Timeout (vhostHTTPTimeout)')">
<n-input v-model:value="model.vhost_http_timeout" placeholder="60" />
</n-form-item>
<n-form-item :label="$gettext('TCPMux HTTP Connect Port (tcpmuxHTTPConnectPort)')">
<n-input
v-model:value="model.tcpmux_http_connect_port"
:placeholder="$gettext('Required for tcpmux type proxies')"
/>
</n-form-item>
<n-form-item :label="$gettext('Custom 404 Page (custom404Page)')">
<n-input v-model:value="model.custom_404_page" placeholder="/path/to/404.html" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="dashboard" :tab="$gettext('Dashboard')">
<n-flex vertical>
<n-alert type="info">
{{ $gettext('The frps dashboard is available only when the port is set.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Listen Address (webServer.addr)')">
<n-input v-model:value="model.web_server_addr" placeholder="127.0.0.1" />
</n-form-item>
<n-form-item :label="$gettext('Listen Port (webServer.port)')">
<n-input v-model:value="model.web_server_port" placeholder="7500" />
</n-form-item>
<n-form-item :label="$gettext('Username (webServer.user)')">
<n-input v-model:value="model.web_server_user" placeholder="admin" />
</n-form-item>
<n-form-item :label="$gettext('Password (webServer.password)')">
<n-input
v-model:value="model.web_server_password"
type="password"
show-password-on="click"
/>
</n-form-item>
<n-form-item :label="$gettext('Enable pprof (webServer.pprofEnable)')">
<n-select
v-model:value="model.web_server_pprof_enable"
:options="boolOptions"
clearable
/>
</n-form-item>
<n-form-item :label="$gettext('Enable Prometheus (enablePrometheus)')">
<n-select v-model:value="model.enable_prometheus" :options="boolOptions" clearable />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="transport" :tab="$gettext('Transport')">
<n-flex vertical>
<n-alert type="warning">
{{ $gettext('Do not modify these options unless you know what they do.') }}
</n-alert>
<n-form>
<n-form-item :label="$gettext('Max Pool Count (transport.maxPoolCount)')">
<n-input v-model:value="model.transport_max_pool_count" placeholder="5" />
</n-form-item>
<n-form-item :label="$gettext('TCP Mux (transport.tcpMux)')">
<n-select v-model:value="model.transport_tcp_mux" :options="boolOptions" clearable />
</n-form-item>
<n-form-item
:label="$gettext('TCP Mux Keepalive Interval (transport.tcpMuxKeepaliveInterval)')"
>
<n-input v-model:value="model.transport_tcp_mux_keepalive_interval" placeholder="30" />
</n-form-item>
<n-form-item :label="$gettext('TCP Keepalive (transport.tcpKeepalive)')">
<n-input v-model:value="model.transport_tcp_keepalive" placeholder="7200" />
</n-form-item>
<n-form-item :label="$gettext('Heartbeat Timeout (transport.heartbeatTimeout)')">
<n-input v-model:value="model.transport_heartbeat_timeout" placeholder="90" />
</n-form-item>
<n-form-item :label="$gettext('Force TLS (transport.tls.force)')">
<n-select v-model:value="model.transport_tls_force" :options="boolOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('TLS Certificate (transport.tls.certFile)')">
<n-input v-model:value="model.transport_tls_cert_file" placeholder="server.crt" />
</n-form-item>
<n-form-item :label="$gettext('TLS Private Key (transport.tls.keyFile)')">
<n-input v-model:value="model.transport_tls_key_file" placeholder="server.key" />
</n-form-item>
<n-form-item :label="$gettext('TLS CA (transport.tls.trustedCaFile)')">
<n-input v-model:value="model.transport_tls_trusted_ca_file" placeholder="ca.crt" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="log" :tab="$gettext('Logs')">
<n-flex vertical>
<n-form>
<n-form-item :label="$gettext('Output (log.to)')">
<n-input v-model:value="model.log_to" placeholder="./frps.log" />
</n-form-item>
<n-form-item :label="$gettext('Level (log.level)')">
<n-select v-model:value="model.log_level" :options="logLevelOptions" clearable />
</n-form-item>
<n-form-item :label="$gettext('Max Days (log.maxDays)')">
<n-input v-model:value="model.log_max_days" placeholder="3" />
</n-form-item>
<n-form-item :label="$gettext('Disable Colors (log.disablePrintColor)')">
<n-select
v-model:value="model.log_disable_print_color"
:options="boolOptions"
clearable
/>
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveLoading"
:disabled="saveLoading"
@click="handleSave"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
+111 -61
View File
@@ -3,14 +3,20 @@ defineOptions({
name: 'apps-frp-index',
})
import { NButton, NFormItem, NInput } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import frp from '@/api/apps/frp'
import ServiceStatus from '@/components/common/ServiceStatus.vue'
import FrpcConfigTuneView from './FrpcConfigTuneView.vue'
import FrpProxyView from './FrpProxyView.vue'
import FrpsConfigTuneView from './FrpsConfigTuneView.vue'
import FrpVisitorView from './FrpVisitorView.vue'
const { $gettext } = useGettext()
const currentTab = ref('frps')
const frpsTab = ref('status')
const frpcTab = ref('status')
const saveUserFrpsLoading = ref(false)
const saveUserFrpcLoading = ref(false)
const config = ref({
@@ -63,70 +69,114 @@ onMounted(() => {
<PageContainer :show-footer="true">
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="frps" tab="Frps">
<n-flex vertical>
<service-status service="frps" />
<n-card :title="$gettext('Run User')">
<template #header-extra>
<n-button
type="primary"
:loading="saveUserFrpsLoading"
:disabled="saveUserFrpsLoading"
@click="handleSaveUser('frps')"
>
{{ $gettext('Save') }}
</n-button>
</template>
<n-flex>
<n-form-item :label="$gettext('User')">
<n-input v-model:value="userInfo.frps.user" :placeholder="$gettext('User')" />
</n-form-item>
<n-form-item :label="$gettext('Group')">
<n-input v-model:value="userInfo.frps.group" :placeholder="$gettext('Group')" />
</n-form-item>
<n-tabs v-model:value="frpsTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<service-status service="frps" />
</n-tab-pane>
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
<frps-config-tune-view />
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Main Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the Frps main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!',
)
}}
</n-alert>
<common-editor v-model:value="config.frps" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig('frps')">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-card>
<n-card :title="$gettext('Modify Configuration')">
<template #header-extra>
<n-button type="primary" @click="handleSaveConfig('frps')">
{{ $gettext('Save') }}
</n-button>
</template>
<common-editor v-model:value="config.frps" height="60vh" />
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="user" :tab="$gettext('Run User')">
<n-flex vertical>
<n-form inline>
<n-form-item :label="$gettext('User')">
<n-input v-model:value="userInfo.frps.user" :placeholder="$gettext('User')" />
</n-form-item>
<n-form-item :label="$gettext('Group')">
<n-input v-model:value="userInfo.frps.group" :placeholder="$gettext('Group')" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveUserFrpsLoading"
:disabled="saveUserFrpsLoading"
@click="handleSaveUser('frps')"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<realtime-log service="frps" />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="frpc" tab="Frpc">
<n-flex vertical>
<service-status service="frpc" />
<n-card :title="$gettext('Run User')">
<template #header-extra>
<n-button
type="primary"
:loading="saveUserFrpcLoading"
:disabled="saveUserFrpcLoading"
@click="handleSaveUser('frpc')"
>
{{ $gettext('Save') }}
</n-button>
</template>
<n-flex>
<n-form-item :label="$gettext('User')">
<n-input v-model:value="userInfo.frpc.user" :placeholder="$gettext('User')" />
</n-form-item>
<n-form-item :label="$gettext('Group')">
<n-input v-model:value="userInfo.frpc.group" :placeholder="$gettext('Group')" />
</n-form-item>
<n-tabs v-model:value="frpcTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<service-status service="frpc" />
</n-tab-pane>
<n-tab-pane name="config-tune" :tab="$gettext('Parameter Tuning')">
<frpc-config-tune-view />
</n-tab-pane>
<n-tab-pane name="proxies" :tab="$gettext('Proxy Management')">
<frp-proxy-view />
</n-tab-pane>
<n-tab-pane name="visitors" :tab="$gettext('Visitor Management')">
<frp-visitor-view />
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Main Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the Frpc main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!',
)
}}
</n-alert>
<common-editor v-model:value="config.frpc" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig('frpc')">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-card>
<n-card :title="$gettext('Modify Configuration')">
<template #header-extra>
<n-button type="primary" @click="handleSaveConfig('frpc')">
{{ $gettext('Save') }}
</n-button>
</template>
<common-editor v-model:value="config.frpc" height="60vh" />
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="user" :tab="$gettext('Run User')">
<n-flex vertical>
<n-form inline>
<n-form-item :label="$gettext('User')">
<n-input v-model:value="userInfo.frpc.user" :placeholder="$gettext('User')" />
</n-form-item>
<n-form-item :label="$gettext('Group')">
<n-input v-model:value="userInfo.frpc.group" :placeholder="$gettext('Group')" />
</n-form-item>
</n-form>
<n-flex>
<n-button
type="primary"
:loading="saveUserFrpcLoading"
:disabled="saveUserFrpcLoading"
@click="handleSaveUser('frpc')"
>
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<realtime-log service="frpc" />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
</n-tabs>
</PageContainer>