mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-19 16:38:31 +00:00
feat: M1 高级传输参数(P0)
- store: Local 增加 useEncryption/useCompression/bandwidthLimit/poolCount/metadatas/annotations;Remote 增加 transportProtocol/transportTls/transportPool/transportTlsServerName;新增 migrate() ALTER TABLE 迁移旧库 - render: 输出 transport 段(protocol/tls/poolCount)与 proxy 高级字段;补 TestRenderTransportAdvanced/OmittedWhenEmpty - main: renderRemote 闭包透传 M1 字段到 render.Proxy - web: LocalNode/RemoteNode 折叠高级区表单,types.ts 接口扩展 - 验证:go test 全绿、vue-tsc/build 通过、端到端 PUT canvas→frpc JSON 输出完整
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css">
|
||||
<script type="module" crossorigin src="/assets/index-D5XN_p_5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B38pSFA7.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -24,6 +24,14 @@ type Proxy struct {
|
||||
SubDomain string
|
||||
// CustomDomains is used by http/https proxies when set.
|
||||
CustomDomains []string
|
||||
|
||||
// Advanced transport knobs (M1): proxy-level encryption/compression/limit.
|
||||
UseEncryption bool
|
||||
UseCompression bool
|
||||
BandwidthLimit string
|
||||
PoolCount int
|
||||
Metadatas map[string]string
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
// frpcAuth mirrors frpc's auth section.
|
||||
@ -34,7 +42,15 @@ type frpcAuth struct {
|
||||
|
||||
// frpcTransport is the transport section used by frpc.
|
||||
type frpcTransport struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
TLS *frpcTLS `json:"tls,omitempty"`
|
||||
PoolCount int `json:"poolCount,omitempty"`
|
||||
}
|
||||
|
||||
// frpcTLS mirrors frpc's transport.tls section.
|
||||
type frpcTLS struct {
|
||||
Enable bool `json:"enable,omitempty"`
|
||||
ServerName string `json:"serverName,omitempty"`
|
||||
}
|
||||
|
||||
// frpcProxy is the serialized proxy object. Different types use different
|
||||
@ -47,6 +63,14 @@ type frpcProxy struct {
|
||||
RemotePort int `json:"remotePort,omitempty"`
|
||||
CustomDomains []string `json:"customDomains,omitempty"`
|
||||
SubDomain string `json:"subdomain,omitempty"`
|
||||
|
||||
// M1 advanced transport fields.
|
||||
UseEncryption bool `json:"useEncryption,omitempty"`
|
||||
UseCompression bool `json:"useCompression,omitempty"`
|
||||
BandwidthLimit string `json:"bandwidthLimit,omitempty"`
|
||||
PoolCount int `json:"poolCount,omitempty"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// Config is a complete frpc configuration document.
|
||||
@ -69,15 +93,33 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
|
||||
LoginFailExit: false,
|
||||
Proxies: make([]frpcProxy, 0, len(proxies)),
|
||||
}
|
||||
if remote.TransportProtocol != "" || remote.TransportTLS || remote.TransportPool > 0 || remote.TransportTLSServerName != "" {
|
||||
cfg.Transport = frpcTransport{
|
||||
Protocol: remote.TransportProtocol,
|
||||
PoolCount: remote.TransportPool,
|
||||
}
|
||||
if remote.TransportTLS || remote.TransportTLSServerName != "" {
|
||||
cfg.Transport.TLS = &frpcTLS{
|
||||
Enable: remote.TransportTLS,
|
||||
ServerName: remote.TransportTLSServerName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nameCounts := make(map[string]int)
|
||||
for _, p := range proxies {
|
||||
frpcP := frpcProxy{
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
LocalIP: p.LocalIP,
|
||||
LocalPort: p.LocalPort,
|
||||
SubDomain: p.SubDomain,
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
LocalIP: p.LocalIP,
|
||||
LocalPort: p.LocalPort,
|
||||
SubDomain: p.SubDomain,
|
||||
UseEncryption: p.UseEncryption,
|
||||
UseCompression: p.UseCompression,
|
||||
BandwidthLimit: p.BandwidthLimit,
|
||||
PoolCount: p.PoolCount,
|
||||
Metadatas: p.Metadatas,
|
||||
Annotations: p.Annotations,
|
||||
}
|
||||
// http/https proxies use customDomains instead of remotePort. The
|
||||
// default domain is <name>.local; frps matches it by Host header.
|
||||
|
||||
@ -46,3 +46,58 @@ func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
|
||||
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTransportAdvanced(t *testing.T) {
|
||||
remote := store.Remote{
|
||||
Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret",
|
||||
TransportProtocol: "kcp", TransportTLS: true, TransportPool: 3, TransportTLSServerName: "frps.local",
|
||||
}
|
||||
data, err := Render(remote, []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080,
|
||||
UseEncryption: true, UseCompression: true, BandwidthLimit: "1MB", PoolCount: 2,
|
||||
Metadatas: map[string]string{"env": "prod"}, Annotations: map[string]string{"owner": "ops"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Transport.Protocol != "kcp" {
|
||||
t.Fatalf("transport.protocol = %q, want kcp", cfg.Transport.Protocol)
|
||||
}
|
||||
if cfg.Transport.TLS == nil || !cfg.Transport.TLS.Enable || cfg.Transport.TLS.ServerName != "frps.local" {
|
||||
t.Fatalf("transport.tls = %+v", cfg.Transport.TLS)
|
||||
}
|
||||
if cfg.Transport.PoolCount != 3 {
|
||||
t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount)
|
||||
}
|
||||
p := cfg.Proxies[0]
|
||||
if !p.UseEncryption || !p.UseCompression || p.BandwidthLimit != "1MB" || p.PoolCount != 2 {
|
||||
t.Fatalf("proxy advanced fields = %+v", p)
|
||||
}
|
||||
if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" {
|
||||
t.Fatalf("proxy metadatas/annotations = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
|
||||
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
|
||||
data, err := Render(remote, []Proxy{
|
||||
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Transport.Protocol != "" || cfg.Transport.TLS != nil || cfg.Transport.PoolCount != 0 {
|
||||
t.Fatalf("transport should be empty, got %+v", cfg.Transport)
|
||||
}
|
||||
if cfg.Proxies[0].UseEncryption || cfg.Proxies[0].BandwidthLimit != "" {
|
||||
t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0])
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@ -18,6 +19,14 @@ type Local struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"` // tcp | udp | http | https
|
||||
|
||||
// Advanced transport knobs (M1). All optional; empty/zero = frpc defaults.
|
||||
UseEncryption bool `json:"useEncryption,omitempty"`
|
||||
UseCompression bool `json:"useCompression,omitempty"`
|
||||
BandwidthLimit string `json:"bandwidthLimit,omitempty"` // e.g. "1MB", "2KB"
|
||||
PoolCount int `json:"poolCount,omitempty"`
|
||||
Metadatas map[string]string `json:"metadatas,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// Remote is a remote server node on the canvas.
|
||||
@ -28,6 +37,12 @@ type Remote struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Transport section (M1). Protocol: tcp | quic | kcp | websocket.
|
||||
TransportProtocol string `json:"transportProtocol,omitempty"`
|
||||
TransportTLS bool `json:"transportTls,omitempty"`
|
||||
TransportPool int `json:"transportPool,omitempty"`
|
||||
TransportTLSServerName string `json:"transportTlsServerName,omitempty"`
|
||||
}
|
||||
|
||||
// Link connects one local to one remote.
|
||||
@ -110,11 +125,65 @@ func New(path string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("enable fk: %w", err)
|
||||
}
|
||||
st := &Store{db: db}
|
||||
if err := st.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("secure db: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// migrate adds columns introduced after the initial schema so old databases
|
||||
// keep working (CREATE TABLE IF NOT EXISTS does not touch existing tables).
|
||||
func (s *Store) migrate() error {
|
||||
tables := map[string][]string{
|
||||
"locals": {
|
||||
"use_encryption INTEGER NOT NULL DEFAULT 0",
|
||||
"use_compression INTEGER NOT NULL DEFAULT 0",
|
||||
"bandwidth_limit TEXT NOT NULL DEFAULT ''",
|
||||
"pool_count INTEGER NOT NULL DEFAULT 0",
|
||||
"metadatas TEXT NOT NULL DEFAULT ''",
|
||||
"annotations TEXT NOT NULL DEFAULT ''",
|
||||
},
|
||||
"remotes": {
|
||||
"transport_protocol TEXT NOT NULL DEFAULT ''",
|
||||
"transport_tls INTEGER NOT NULL DEFAULT 0",
|
||||
"transport_pool INTEGER NOT NULL DEFAULT 0",
|
||||
"transport_tls_server_name TEXT NOT NULL DEFAULT ''",
|
||||
},
|
||||
}
|
||||
for table, cols := range tables {
|
||||
rows, err := s.db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
rows.Close()
|
||||
for _, def := range cols {
|
||||
name := def[:strings.Index(def, " ")]
|
||||
if !have[name] {
|
||||
if _, err := s.db.Exec("ALTER TABLE " + table + " ADD COLUMN " + def); err != nil {
|
||||
return fmt.Errorf("migrate %s.%s: %w", table, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
@ -122,7 +191,7 @@ func (s *Store) Close() error { return s.db.Close() }
|
||||
// ---- Locals ----
|
||||
|
||||
func (s *Store) ListLocals() ([]Local, error) {
|
||||
rows, err := s.db.Query("SELECT name, ip, port, protocol FROM locals ORDER BY name")
|
||||
rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations FROM locals ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -130,9 +199,15 @@ func (s *Store) ListLocals() ([]Local, error) {
|
||||
var out []Local
|
||||
for rows.Next() {
|
||||
var l Local
|
||||
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
var enc, comp int
|
||||
var metaRaw, annoRaw string
|
||||
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.UseEncryption = enc != 0
|
||||
l.UseCompression = comp != 0
|
||||
l.Metadatas = decodeMap(metaRaw)
|
||||
l.Annotations = decodeMap(annoRaw)
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@ -140,22 +215,52 @@ func (s *Store) ListLocals() ([]Local, error) {
|
||||
|
||||
func (s *Store) GetLocal(name string) (Local, bool) {
|
||||
var l Local
|
||||
row := s.db.QueryRow("SELECT name, ip, port, protocol FROM locals WHERE name = ?", name)
|
||||
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
|
||||
var enc, comp int
|
||||
var metaRaw, annoRaw string
|
||||
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations FROM locals WHERE name = ?", name)
|
||||
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw); err != nil {
|
||||
return Local{}, false
|
||||
}
|
||||
l.UseEncryption = enc != 0
|
||||
l.UseCompression = comp != 0
|
||||
l.Metadatas = decodeMap(metaRaw)
|
||||
l.Annotations = decodeMap(annoRaw)
|
||||
return l, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertLocal(l Local) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO locals(name, ip, port, protocol) VALUES(?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol",
|
||||
l.Name, l.IP, l.Port, l.Protocol,
|
||||
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations) VALUES(?,?,?,?,?,?,?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations",
|
||||
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// encodeMap serializes an optional JSON map so nil stays an empty string.
|
||||
func encodeMap(m map[string]string) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// decodeMap reads a JSON map column; empty/invalid becomes nil.
|
||||
func decodeMap(raw string) map[string]string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Store) DeleteLocal(name string) error {
|
||||
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
|
||||
return err
|
||||
@ -164,7 +269,7 @@ func (s *Store) DeleteLocal(name string) error {
|
||||
// ---- Remotes ----
|
||||
|
||||
func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled FROM remotes ORDER BY name")
|
||||
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name FROM remotes ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -172,11 +277,12 @@ func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
var out []Remote
|
||||
for rows.Next() {
|
||||
var r Remote
|
||||
var en int
|
||||
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
var en, tls int
|
||||
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
r.TransportTLS = tls != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@ -184,20 +290,21 @@ func (s *Store) ListRemotes() ([]Remote, error) {
|
||||
|
||||
func (s *Store) GetRemote(name string) (Remote, bool) {
|
||||
var r Remote
|
||||
var en int
|
||||
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled FROM remotes WHERE name = ?", name)
|
||||
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
|
||||
var en, tls int
|
||||
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name FROM remotes WHERE name = ?", name)
|
||||
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName); err != nil {
|
||||
return Remote{}, false
|
||||
}
|
||||
r.Enabled = en != 0
|
||||
r.TransportTLS = tls != 0
|
||||
return r, true
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRemote(r Remote) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO remotes(name, ip, port, token, url, enabled) VALUES(?,?,?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled",
|
||||
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled),
|
||||
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name) VALUES(?,?,?,?,?,?,?,?,?,?) "+
|
||||
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name",
|
||||
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
8
plan.md
8
plan.md
@ -41,10 +41,10 @@
|
||||
|
||||
目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。
|
||||
|
||||
- [ ] Local 增加高级字段:useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations(渲染器 + 编辑表单 + 折叠 UI)
|
||||
- [ ] Remote 增加 transport:protocol(tcp/quic/kcp/websocket)、tls.enable、poolCount
|
||||
- [ ] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
|
||||
- [ ] 高级字段随 canvas API 持久化(store 表扩展 + 迁移)
|
||||
- [x] Local 增加高级字段:useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations(渲染器 + 编辑表单 + 折叠 UI)
|
||||
- [x] Remote 增加 transport:protocol(tcp/quic/kcp/websocket)、tls.enable、poolCount
|
||||
- [x] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
|
||||
- [x] 高级字段随 canvas API 持久化(store 表扩展 + 迁移)
|
||||
|
||||
### M2 HTTP/HTTPS 完善
|
||||
|
||||
|
||||
@ -28,12 +28,32 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="adv">
|
||||
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
||||
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
||||
</button>
|
||||
<div v-if="showAdv" class="adv-body">
|
||||
<label class="adv-check">
|
||||
<input v-model="useEncryptionField" type="checkbox" /> 加密
|
||||
(useEncryption)
|
||||
</label>
|
||||
<label class="adv-check">
|
||||
<input v-model="useCompressionField" type="checkbox" /> 压缩
|
||||
(useCompression)
|
||||
</label>
|
||||
<label>限速 <input v-model="bandwidthLimitField" placeholder="如 1MB" /></label>
|
||||
<label>连接池 <input v-model.number="poolCountField" type="number" min="0" /></label>
|
||||
<label>元数据(JSON) <input v-model="metadatasField" placeholder='{"env":"prod"}' /></label>
|
||||
<label>注解(JSON) <input v-model="annotationsField" placeholder='{"owner":"ops"}' /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CanvasLocal } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -59,6 +79,41 @@ const nameField = field('name')
|
||||
const ipField = field('ip')
|
||||
const portField = field('port')
|
||||
const protocolField = field('protocol')
|
||||
const useEncryptionField = field('useEncryption')
|
||||
const useCompressionField = field('useCompression')
|
||||
const bandwidthLimitField = field('bandwidthLimit')
|
||||
const poolCountField = field('poolCount')
|
||||
|
||||
// map fields shown as JSON strings in the UI
|
||||
const showAdv = ref(false)
|
||||
const metadatasField = computed({
|
||||
get: () => JSON.stringify(props.data.metadatas ?? {}),
|
||||
set: (v: string) => {
|
||||
let parsed: Record<string, string> = {}
|
||||
if (v.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(v)
|
||||
} catch {
|
||||
return // keep old value on invalid JSON
|
||||
}
|
||||
}
|
||||
emit('update:data', { ...props.data, metadatas: parsed })
|
||||
},
|
||||
})
|
||||
const annotationsField = computed({
|
||||
get: () => JSON.stringify(props.data.annotations ?? {}),
|
||||
set: (v: string) => {
|
||||
let parsed: Record<string, string> = {}
|
||||
if (v.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(v)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('update:data', { ...props.data, annotations: parsed })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -162,5 +217,54 @@ const protocolField = field('protocol')
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.adv {
|
||||
margin-top: 6px;
|
||||
.adv-toggle {
|
||||
border: 1px dashed #a5d6a7;
|
||||
background: transparent;
|
||||
color: #558b2f;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adv-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(76, 175, 80, 0.06);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #558b2f;
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #a5d6a7;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
}
|
||||
}
|
||||
.adv-check {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -25,12 +25,34 @@
|
||||
<label>令牌 <input v-model="tokenField" /></label>
|
||||
<label>URL <input v-model="urlField" /></label>
|
||||
</div>
|
||||
|
||||
<div class="adv">
|
||||
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
||||
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
||||
</button>
|
||||
<div v-if="showAdv" class="adv-body">
|
||||
<label
|
||||
>传输协议
|
||||
<select v-model="transportProtocolField">
|
||||
<option value="">tcp(默认)</option>
|
||||
<option value="quic">quic</option>
|
||||
<option value="kcp">kcp</option>
|
||||
<option value="websocket">websocket</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="adv-check">
|
||||
<input v-model="transportTlsField" type="checkbox" /> TLS 传输
|
||||
</label>
|
||||
<label>TLS SNI <input v-model="transportTlsServerNameField" placeholder="frps.local" /></label>
|
||||
<label>连接池 <input v-model.number="transportPoolField" type="number" min="0" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CanvasRemote } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -58,6 +80,11 @@ const portField = field('port')
|
||||
const tokenField = field('token')
|
||||
const urlField = field('url')
|
||||
const enabledField = field('enabled')
|
||||
const transportProtocolField = field('transportProtocol')
|
||||
const transportTlsField = field('transportTls')
|
||||
const transportTlsServerNameField = field('transportTlsServerName')
|
||||
const transportPoolField = field('transportPool')
|
||||
const showAdv = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -168,5 +195,54 @@ const enabledField = field('enabled')
|
||||
flex: 0 0 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.adv {
|
||||
margin-top: 6px;
|
||||
.adv-toggle {
|
||||
border: 1px dashed #ffcc80;
|
||||
background: transparent;
|
||||
color: #ef6c00;
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adv-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 152, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #ef6c00;
|
||||
|
||||
input,
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #ffcc80;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
}
|
||||
}
|
||||
.adv-check {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -13,6 +13,14 @@ export interface Local {
|
||||
ip: string
|
||||
port: number
|
||||
protocol: string // tcp | udp | http | https
|
||||
|
||||
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
|
||||
useEncryption?: boolean
|
||||
useCompression?: boolean
|
||||
bandwidthLimit?: string
|
||||
poolCount?: number
|
||||
metadatas?: Record<string, string>
|
||||
annotations?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Remote {
|
||||
@ -22,6 +30,12 @@ export interface Remote {
|
||||
token?: string
|
||||
url?: string
|
||||
enabled: boolean
|
||||
|
||||
// M1 transport section
|
||||
transportProtocol?: string // tcp | quic | kcp | websocket
|
||||
transportTls?: boolean
|
||||
transportPool?: number
|
||||
transportTlsServerName?: string
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Backend default port (cmd/webui4frpc -addr). Override with VITE_PROXY_TARGET.
|
||||
const backend = process.env.VITE_PROXY_TARGET || 'http://127.0.0.1:7500'
|
||||
const backend = process.env.VITE_PROXY_TARGET || "http://127.0.0.1:7500";
|
||||
|
||||
// The frontend is served by the Go binary at / (embedded dist), and talks to
|
||||
// the same origin via /api/manager/*.
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/',
|
||||
base: "/",
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
@ -21,11 +21,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: "dist",
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
"/api": backend,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user