diff --git a/internal/render/render.go b/internal/render/render.go index 4d03316..d4aee49 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -25,13 +25,19 @@ type Proxy struct { // CustomDomains is used by http/https proxies when set. CustomDomains []string - // Advanced transport knobs (M1): proxy-level encryption/compression/limit. + // Advanced transport knobs (M1). These are rendered INSIDE the proxy's + // `transport` object (frpc's ProxyTransport), not at the proxy top level. UseEncryption bool UseCompression bool BandwidthLimit string - PoolCount int - Metadatas map[string]string - Annotations map[string]string + // PoolCount has no per-proxy representation in frpc (ProxyTransport has no + // poolCount field; only the global `transport.poolCount` exists), so this + // value is deliberately not rendered — use the remote's TransportPool for + // connection pooling. Kept here so existing canvases keep loading. + PoolCount int + + Metadatas map[string]string + Annotations map[string]string // M2 HTTP/HTTPS routing (http/https only). Locations []string // path routing @@ -73,9 +79,15 @@ type frpcTLS struct { ServerName string `json:"serverName,omitempty"` } -// frpcProxyTransport is the per-proxy transport section (frpc >= 0.52) -// where bandwidthLimit is defined. +// frpcProxyTransport is the per-proxy transport section (frpc >= 0.52). +// +// useEncryption/useCompression/bandwidthLimit all live HERE, not at the proxy's +// top level: frpc's ProxyTransport struct owns them, and frpc runs with +// --strict-config by default, so a top-level spelling fails the whole config +// with `unmarshal ProxyConfig error: json: unknown field "useEncryption"`. type frpcProxyTransport struct { + UseEncryption bool `json:"useEncryption,omitempty"` + UseCompression bool `json:"useCompression,omitempty"` BandwidthLimit string `json:"bandwidthLimit,omitempty"` } @@ -96,13 +108,9 @@ type frpcProxy struct { CustomDomains []string `json:"customDomains,omitempty"` SubDomain string `json:"subdomain,omitempty"` - // M1 advanced transport fields. bandwidthLimit lives in transport. - UseEncryption bool `json:"useEncryption,omitempty"` - UseCompression bool `json:"useCompression,omitempty"` - PoolCount int `json:"poolCount,omitempty"` - Metadatas map[string]string `json:"metadatas,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - Transport *frpcProxyTransport `json:"transport,omitempty"` + Metadatas map[string]string `json:"metadatas,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Transport *frpcProxyTransport `json:"transport,omitempty"` // M2 HTTP/HTTPS routing. Locations []string `json:"locations,omitempty"` @@ -148,8 +156,12 @@ type frpcHTTPHeader struct { // Config is a complete frpc configuration document. type Config struct { - ServerAddr string `json:"serverAddr"` - ServerPort int `json:"serverPort"` + ServerAddr string `json:"serverAddr"` + ServerPort int `json:"serverPort"` + // User is frpc's global `user` field. frps prefixes every proxy name with it + // (`.`), and panel-managed frps requires it to admit the login. + // Omitted when empty so plain frps sees an unchanged config. + User string `json:"user,omitempty"` Auth frpcAuth `json:"auth"` Transport frpcTransport `json:"transport,omitempty"` WebServer *frpcWebServer `json:"webServer,omitempty"` @@ -163,6 +175,7 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) { cfg := Config{ ServerAddr: remote.IP, ServerPort: remote.Port, + User: remote.User, Auth: frpcAuth{Method: "token", Token: remote.Token}, LoginFailExit: false, Proxies: make([]frpcProxy, 0, len(proxies)), @@ -200,17 +213,21 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) { LocalIP: p.LocalIP, LocalPort: p.LocalPort, SubDomain: p.SubDomain, - UseEncryption: p.UseEncryption, - UseCompression: p.UseCompression, - PoolCount: p.PoolCount, Metadatas: p.Metadatas, Annotations: p.Annotations, Locations: p.Locations, HostHeaderRewrite: p.HostHeaderRewrite, HTTPHeaders: p.HTTPHeaders, } - if p.BandwidthLimit != "" { - frpcP.Transport = &frpcProxyTransport{BandwidthLimit: p.BandwidthLimit} + // useEncryption/useCompression/bandwidthLimit are all members of frpc's + // per-proxy `transport` object — emitting them at the proxy top level + // makes frpc reject the entire config under --strict-config. + if p.UseEncryption || p.UseCompression || p.BandwidthLimit != "" { + frpcP.Transport = &frpcProxyTransport{ + UseEncryption: p.UseEncryption, + UseCompression: p.UseCompression, + BandwidthLimit: p.BandwidthLimit, + } } if p.BasicAuthUser != "" || p.BasicAuthPassword != "" { frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword} diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 6b80bbb..8891fb9 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -74,14 +74,31 @@ func TestRenderTransportAdvanced(t *testing.T) { t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount) } p := cfg.Proxies[0] - if !p.UseEncryption || !p.UseCompression || p.PoolCount != 2 { - t.Fatalf("proxy advanced fields = %+v", p) + // useEncryption/useCompression/bandwidthLimit belong to the proxy's nested + // `transport` object. Asserting them on the proxy top level is what let the + // old renderer ship a config frpc rejects outright (`unknown field + // "useEncryption"` under --strict-config). + if p.Transport == nil { + t.Fatalf("proxy transport section missing: %+v", p) + } + if !p.Transport.UseEncryption || !p.Transport.UseCompression || p.Transport.BandwidthLimit != "1MB" { + t.Fatalf("proxy transport = %+v", p.Transport) } if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" { t.Fatalf("proxy metadatas/annotations = %+v", p) } - if p.Transport == nil || p.Transport.BandwidthLimit != "1MB" { - t.Fatalf("proxy transport.bandwidthLimit = %+v", p.Transport) + // Regression guard: the top level must carry no encryption/pool keys, since + // frpc runs with --strict-config and rejects unknown fields. + var raw struct { + Proxies []map[string]any `json:"proxies"` + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + for _, bad := range []string{"useEncryption", "useCompression", "poolCount"} { + if _, ok := raw.Proxies[0][bad]; ok { + t.Fatalf("proxy top level must not carry %q: %s", bad, data) + } } } @@ -100,8 +117,8 @@ func TestRenderTransportOmittedWhenEmpty(t *testing.T) { 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].Transport != nil { - t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0]) + if cfg.Proxies[0].Transport != nil { + t.Fatalf("proxy transport should be empty when no proxy-level knobs are set, got %+v", cfg.Proxies[0].Transport) } } @@ -264,3 +281,47 @@ func TestRenderAdminWebServerOmitted(t *testing.T) { t.Fatalf("webServer should be omitted, got %+v", cfg.WebServer) } } + +// A panel-managed frps refuses logins without `user`, so the rendered config +// must carry it verbatim when set. +func TestRenderEmitsUserWhenSet(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "tok", User: "e8f52adfaf0e5c95"} + data, err := Render(remote, []Proxy{ + {Name: "club", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 80, RemotePort: 62962}, + }) + if err != nil { + t.Fatal(err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if cfg.User != "e8f52adfaf0e5c95" { + t.Fatalf("user = %q, want e8f52adfaf0e5c95", cfg.User) + } + // The key must be present on the wire, not merely in the parsed struct: + // an omitempty regression would silently break panel-type frps again. + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["user"]; !ok { + t.Fatalf("rendered JSON has no user key: %s", data) + } +} + +// Plain frps rejects unknown fields, so an empty user must be omitted entirely. +func TestRenderOmitsUserWhenEmpty(t *testing.T) { + remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000} + data, err := Render(remote, nil) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["user"]; ok { + t.Fatalf("empty user must be omitted, got: %s", data) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index b32b6e1..d65d819 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -73,6 +73,12 @@ type Remote struct { URL string `json:"url,omitempty"` Enabled bool `json:"enabled"` + // User is frpc's global `user` field: the proxy names reported to frps become + // spellings. Panel-managed frps installations require it for login (without + // it the server answers `panel authentication unavailable: panel HTTP status + // 403`), while plain frps ignores it. Empty = omitted from the rendered config. + User string `json:"user,omitempty"` + // Transport section (M1). Protocol: tcp | quic | kcp | websocket. TransportProtocol string `json:"transportProtocol,omitempty"` TransportTLS bool `json:"transportTls,omitempty"` @@ -322,6 +328,7 @@ func (s *Store) migrate() error { "transport_tls INTEGER NOT NULL DEFAULT 0", "transport_pool INTEGER NOT NULL DEFAULT 0", "transport_tls_server_name TEXT NOT NULL DEFAULT ''", + "usr TEXT NOT NULL DEFAULT ''", "vhost_http_port INTEGER NOT NULL DEFAULT 0", "admin_addr TEXT NOT NULL DEFAULT ''", "admin_port INTEGER NOT NULL DEFAULT 0", @@ -476,7 +483,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, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password 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, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name") if err != nil { return nil, err } @@ -485,7 +492,7 @@ func (s *Store) ListRemotes() ([]Remote, error) { for rows.Next() { var r Remote 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, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { + if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.User, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { return nil, err } r.Enabled = en != 0 @@ -498,8 +505,8 @@ func (s *Store) ListRemotes() ([]Remote, error) { func (s *Store) GetRemote(name string) (Remote, bool) { var r Remote 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, vhost_http_port, admin_addr, admin_port, admin_user, admin_password 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, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { + row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password 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, &r.User, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil { return Remote{}, false } r.Enabled = en != 0 @@ -509,9 +516,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) { func (s *Store) UpsertRemote(r Remote) error { _, err := s.db.Exec( - "INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) 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, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password", - r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword, + "INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) 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, usr=excluded.usr, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password", + r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.User, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword, ) return err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 14dad3b..7d1ba43 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -58,3 +58,63 @@ func TestReplaceLinksClearsOld(t *testing.T) { t.Fatalf("links = %+v", links) } } + +// `usr` is a reserved SQL keyword in several dialects, so the remotes column is +// named `usr`. This guards the mapping in both directions, including the +// migrate() path that adds the column to pre-existing databases. +func TestRemoteUserRoundTrip(t *testing.T) { + st, err := New(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + // A remote written without user (existing installs) must read back empty. + if err := st.UpsertRemote(Remote{Name: "plain", IP: "1.2.3.4", Port: 7000, Enabled: true}); err != nil { + t.Fatal(err) + } + if got, ok := st.GetRemote("plain"); !ok || got.User != "" { + t.Fatalf("plain remote user = %q ok=%v, want empty", got.User, ok) + } + + want := Remote{Name: "panel", IP: "103.40.13.126", Port: 59999, Token: "tk", User: "e8f52adfaf0e5c95", Enabled: true} + if err := st.UpsertRemote(want); err != nil { + t.Fatal(err) + } + got, ok := st.GetRemote("panel") + if !ok || got.User != want.User { + t.Fatalf("user not persisted: %+v", got) + } + list, err := st.ListRemotes() + if err != nil { + t.Fatal(err) + } + var found bool + for _, r := range list { + if r.Name == "panel" { + found = true + if r.User != want.User { + t.Fatalf("ListRemotes user = %q, want %q", r.User, want.User) + } + } + } + if !found { + t.Fatal("panel remote missing from ListRemotes") + } + + // Updating in place must not drop the column, and clearing it must work. + want.Token = "tk2" + if err := st.UpsertRemote(want); err != nil { + t.Fatal(err) + } + if got, _ := st.GetRemote("panel"); got.User != want.User || got.Token != "tk2" { + t.Fatalf("upsert lost fields: %+v", got) + } + want.User = "" + if err := st.UpsertRemote(want); err != nil { + t.Fatal(err) + } + if got, _ := st.GetRemote("panel"); got.User != "" { + t.Fatalf("user not cleared: %q", got.User) + } +} diff --git a/web/src/components/RemoteNode.vue b/web/src/components/RemoteNode.vue index f5c1640..89b632b 100644 --- a/web/src/components/RemoteNode.vue +++ b/web/src/components/RemoteNode.vue @@ -51,6 +51,12 @@ + + +
+ 面板型 frps 必须填 user,否则登录被拒(panel HTTP status 403);留空则该项不出现在生成的配置里。 +
+