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:
2026-08-17 12:28:54 +08:00
parent e73bd834a1
commit 07f25ea067
11 changed files with 460 additions and 62 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title> <title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script> <script type="module" crossorigin src="/assets/index-D5XN_p_5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css"> <link rel="stylesheet" crossorigin href="/assets/index-B38pSFA7.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -24,6 +24,14 @@ type Proxy struct {
SubDomain string SubDomain string
// CustomDomains is used by http/https proxies when set. // CustomDomains is used by http/https proxies when set.
CustomDomains []string 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. // frpcAuth mirrors frpc's auth section.
@ -35,6 +43,14 @@ type frpcAuth struct {
// frpcTransport is the transport section used by frpc. // frpcTransport is the transport section used by frpc.
type frpcTransport struct { 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 // frpcProxy is the serialized proxy object. Different types use different
@ -47,6 +63,14 @@ type frpcProxy struct {
RemotePort int `json:"remotePort,omitempty"` RemotePort int `json:"remotePort,omitempty"`
CustomDomains []string `json:"customDomains,omitempty"` CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,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. // Config is a complete frpc configuration document.
@ -69,6 +93,18 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
LoginFailExit: false, LoginFailExit: false,
Proxies: make([]frpcProxy, 0, len(proxies)), 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) nameCounts := make(map[string]int)
for _, p := range proxies { for _, p := range proxies {
@ -78,6 +114,12 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
LocalIP: p.LocalIP, LocalIP: p.LocalIP,
LocalPort: p.LocalPort, LocalPort: p.LocalPort,
SubDomain: p.SubDomain, 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 // http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header. // default domain is <name>.local; frps matches it by Host header.

View File

@ -46,3 +46,58 @@ func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name) 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])
}
}

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -18,6 +19,14 @@ type Local struct {
IP string `json:"ip"` IP string `json:"ip"`
Port int `json:"port"` Port int `json:"port"`
Protocol string `json:"protocol"` // tcp | udp | http | https 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. // Remote is a remote server node on the canvas.
@ -28,6 +37,12 @@ type Remote struct {
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Enabled bool `json:"enabled"` 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. // Link connects one local to one remote.
@ -110,11 +125,65 @@ func New(path string) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("enable fk: %w", err) 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 { if err := os.Chmod(path, 0o600); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("secure db: %w", err) 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, &notnull, &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() } func (s *Store) Close() error { return s.db.Close() }
@ -122,7 +191,7 @@ func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ---- // ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -130,9 +199,15 @@ func (s *Store) ListLocals() ([]Local, error) {
var out []Local var out []Local
for rows.Next() { for rows.Next() {
var l Local 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 return nil, err
} }
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
out = append(out, l) out = append(out, l)
} }
return out, rows.Err() return out, rows.Err()
@ -140,22 +215,52 @@ func (s *Store) ListLocals() ([]Local, error) {
func (s *Store) GetLocal(name string) (Local, bool) { func (s *Store) GetLocal(name string) (Local, bool) {
var l Local var l Local
row := s.db.QueryRow("SELECT name, ip, port, protocol FROM locals WHERE name = ?", name) var enc, comp int
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil { 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 return Local{}, false
} }
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
return l, true return l, true
} }
func (s *Store) UpsertLocal(l Local) error { func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol) VALUES(?,?,?,?) "+ "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", "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, 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 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 { func (s *Store) DeleteLocal(name string) error {
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name) _, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
return err return err
@ -164,7 +269,7 @@ func (s *Store) DeleteLocal(name string) error {
// ---- Remotes ---- // ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -172,11 +277,12 @@ func (s *Store) ListRemotes() ([]Remote, error) {
var out []Remote var out []Remote
for rows.Next() { for rows.Next() {
var r Remote var r Remote
var en int var en, tls int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil { 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 return nil, err
} }
r.Enabled = en != 0 r.Enabled = en != 0
r.TransportTLS = tls != 0
out = append(out, r) out = append(out, r)
} }
return out, rows.Err() return out, rows.Err()
@ -184,20 +290,21 @@ func (s *Store) ListRemotes() ([]Remote, error) {
func (s *Store) GetRemote(name string) (Remote, bool) { func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote var r Remote
var en int var en, tls int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled FROM remotes WHERE name = ?", name) 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); err != nil { 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 return Remote{}, false
} }
r.Enabled = en != 0 r.Enabled = en != 0
r.TransportTLS = tls != 0
return r, true return r, true
} }
func (s *Store) UpsertRemote(r Remote) error { func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled) VALUES(?,?,?,?,?,?) "+ "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", "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.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName,
) )
return err return err
} }

View File

@ -41,10 +41,10 @@
目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。 目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。
- [ ] Local 增加高级字段useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations渲染器 + 编辑表单 + 折叠 UI - [x] Local 增加高级字段useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations渲染器 + 编辑表单 + 折叠 UI
- [ ] Remote 增加 transportprotocoltcp/quic/kcp/websocket、tls.enable、poolCount - [x] Remote 增加 transportprotocoltcp/quic/kcp/websocket、tls.enable、poolCount
- [ ] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段 - [x] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
- [ ] 高级字段随 canvas API 持久化store 表扩展 + 迁移) - [x] 高级字段随 canvas API 持久化store 表扩展 + 迁移)
### M2 HTTP/HTTPS 完善 ### M2 HTTP/HTTPS 完善

View File

@ -28,12 +28,32 @@
</select> </select>
</label> </label>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Handle, Position } from '@vue-flow/core' import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue' import { computed, ref } from 'vue'
import type { CanvasLocal } from '../types' import type { CanvasLocal } from '../types'
const props = defineProps<{ const props = defineProps<{
@ -59,6 +79,41 @@ const nameField = field('name')
const ipField = field('ip') const ipField = field('ip')
const portField = field('port') const portField = field('port')
const protocolField = field('protocol') 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -162,5 +217,54 @@ const protocolField = field('protocol')
flex: 0 0 64px; 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> </style>

View File

@ -25,12 +25,34 @@
<label>令牌 <input v-model="tokenField" /></label> <label>令牌 <input v-model="tokenField" /></label>
<label>URL <input v-model="urlField" /></label> <label>URL <input v-model="urlField" /></label>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Handle, Position } from '@vue-flow/core' import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue' import { computed, ref } from 'vue'
import type { CanvasRemote } from '../types' import type { CanvasRemote } from '../types'
const props = defineProps<{ const props = defineProps<{
@ -58,6 +80,11 @@ const portField = field('port')
const tokenField = field('token') const tokenField = field('token')
const urlField = field('url') const urlField = field('url')
const enabledField = field('enabled') 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> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -168,5 +195,54 @@ const enabledField = field('enabled')
flex: 0 0 64px; 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> </style>

View File

@ -13,6 +13,14 @@ export interface Local {
ip: string ip: string
port: number port: number
protocol: string // tcp | udp | http | https 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 { export interface Remote {
@ -22,6 +30,12 @@ export interface Remote {
token?: string token?: string
url?: string url?: string
enabled: boolean enabled: boolean
// M1 transport section
transportProtocol?: string // tcp | quic | kcp | websocket
transportTls?: boolean
transportPool?: number
transportTlsServerName?: string
} }
export interface Link { export interface Link {

View File

@ -1,17 +1,17 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import vue from '@vitejs/plugin-vue' import vue from "@vitejs/plugin-vue";
import { fileURLToPath } from 'node:url' import { fileURLToPath } from "node:url";
// Backend default port (cmd/webui4frpc -addr). Override with VITE_PROXY_TARGET. // 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 frontend is served by the Go binary at / (embedded dist), and talks to
// the same origin via /api/manager/*. // the same origin via /api/manager/*.
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
base: '/', base: "/",
resolve: { resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
@ -21,11 +21,11 @@ export default defineConfig({
}, },
}, },
build: { build: {
outDir: 'dist', outDir: "dist",
}, },
server: { server: {
proxy: { proxy: {
'/api': backend, "/api": backend,
}, },
}, },
}) });