mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-29 02:10:58 +08:00
feat: Frp 管理器支持可视化配置
frps/frpc 参数表单基于 confval 新增的 TOML 编解码就地改写主配置,保留注释与键顺序。 代理与访问者写入独立的 conf.d 目录,主配置只注入一行 includes,不接管其中手写的代理。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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_.-]+$\""`
|
||||
}
|
||||
|
||||
@@ -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'},
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user