mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-31 06:17:20 +00:00
Compare commits
3 Commits
feat/confi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1878401d3a | |||
| d83e5d8e70 | |||
| b29cd0d186 |
12
README.md
12
README.md
@ -87,7 +87,7 @@ cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist intern
|
||||
### Linux (Debian / Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo dpkg -i webui4frpc-0.1.4-linux-amd64.deb # 或 linux-arm64.deb
|
||||
sudo dpkg -i webui4frpc-0.2.0-linux-amd64.deb # 或 linux-arm64.deb
|
||||
sudo nano /etc/webui4frpc/config.json # 改掉默认密码(见「从配置文件启动」)
|
||||
sudo systemctl restart webui4frpc
|
||||
```
|
||||
@ -95,7 +95,7 @@ sudo systemctl restart webui4frpc
|
||||
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
|
||||
|
||||
```bash
|
||||
sudo rpm -i webui4frpc-0.1.4-linux-x86_64.rpm
|
||||
sudo rpm -i webui4frpc-0.2.0-linux-x86_64.rpm
|
||||
sudo vi /etc/webui4frpc/config.json
|
||||
sudo systemctl enable --now webui4frpc
|
||||
```
|
||||
@ -105,7 +105,7 @@ sudo systemctl enable --now webui4frpc
|
||||
|
||||
### Windows
|
||||
|
||||
双击 `webui4frpc-0.1.4-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||
双击 `webui4frpc-0.2.0-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||
安装向导会询问监听地址与管理员凭据,完成后:
|
||||
|
||||
- 程序装到 `C:\Program Files\webui4frpc`
|
||||
@ -115,8 +115,8 @@ sudo systemctl enable --now webui4frpc
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
sudo installer -pkg webui4frpc-0.1.4-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.1.4-darwin-amd64.pkg -target / # Intel
|
||||
sudo installer -pkg webui4frpc-0.2.0-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.2.0-darwin-amd64.pkg -target / # Intel
|
||||
```
|
||||
|
||||
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
|
||||
@ -134,7 +134,7 @@ sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
|
||||
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
|
||||
|
||||
```bash
|
||||
tar xzf webui4frpc-0.1.4-linux-amd64.tar.gz
|
||||
tar xzf webui4frpc-0.2.0-linux-amd64.tar.gz
|
||||
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
|
||||
```
|
||||
|
||||
|
||||
@ -25,11 +25,17 @@ 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 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
|
||||
|
||||
@ -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,10 +108,6 @@ 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"`
|
||||
@ -150,6 +158,10 @@ type frpcHTTPHeader struct {
|
||||
type Config struct {
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerPort int `json:"serverPort"`
|
||||
// User is frpc's global `user` field. frps prefixes every proxy name with it
|
||||
// (`<user>.<name>`), 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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
//
|
||||
// Value is overridable at build time so release artifacts carry the real tag:
|
||||
//
|
||||
// go build -ldflags="-X webui4frpc/internal/version.Version=0.1.4" ./cmd/webui4frpc
|
||||
// go build -ldflags="-X webui4frpc/internal/version.Version=0.2.0" ./cmd/webui4frpc
|
||||
//
|
||||
// Kept in its own package because both cmd/ and internal/httpapi report it
|
||||
// (main can't be imported, and httpapi must not depend on cmd).
|
||||
package version
|
||||
|
||||
// Version is the running build's semantic version, without a leading "v".
|
||||
var Version = "0.1.4"
|
||||
var Version = "0.2.0"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
Name: webui4frpc
|
||||
Version: %{?_w4f_version}%{!?_w4f_version:0.1.4}
|
||||
Version: %{?_w4f_version}%{!?_w4f_version:0.2.0}
|
||||
Release: 1%{?dist}
|
||||
Summary: Visual frpc controller with token-ring cluster
|
||||
License: MIT
|
||||
@ -74,6 +74,15 @@ fi
|
||||
%doc %{_docdir}/webui4frpc/README.md
|
||||
|
||||
%changelog
|
||||
* Fri Oct 23 2026 JianFeeeee <jianfeeeee@gitcode> - 0.2.0-1
|
||||
- 从配置文件启动:新增 internal/appconfig,-config 指定配置文件(默认 <workdir>/config.json,$W4F_CONFIG 兜底)
|
||||
- 从配置文件启动:优先级 flag > 文件 > 内置默认,仅显式传入的 flag 参与覆盖
|
||||
- 从配置文件启动:支持 ${ENV} 与 file:<path> 两种密钥引用,口令/join-key 在日志与启动摘要中固定脱敏
|
||||
- 从配置文件启动:未配口令保持旧行为并打 WARNING;文件 JSON 损坏或含未知键则报错退出(防口令拼错静默回落)
|
||||
- 打包:systemd 单元与 deb/rpm/pkg/NSIS 全部改为 ExecStart=-config /etc/webui4frpc/config.json,命令行不再出现口令
|
||||
- 修复渲染:useEncryption/useCompression/bandwidthLimit 内嵌进 proxy 的 transport 对象(此前渲染在顶层,frpc --strict-config 拒载)
|
||||
- 新增 frpc 全局 user 字段(store 列名 usr,自动加列迁移),支持面板型 frps 强制 user 登录
|
||||
- 前端:RemoteNode 画布与 StatusView 表单新增 user 输入,带「面板型 frps 必填」提示
|
||||
* Mon Oct 06 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.4-1
|
||||
- 负载均衡:改为每节点自主释放(持有数超过平摊份额且 >=2 时自撤销一条丢回环,由最闲节点认领),不再由 leader 统一调度
|
||||
- 负载均衡:新增自限上界与下限(撤到平摊值即停、低于 2 条不撤),10 分钟冷却,默认不动作(dry-run)
|
||||
|
||||
@ -79,7 +79,7 @@ cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist intern
|
||||
### Linux (Debian / Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo dpkg -i webui4frpc-0.1.4-linux-amd64.deb # 或 linux-arm64.deb
|
||||
sudo dpkg -i webui4frpc-0.2.0-linux-amd64.deb # 或 linux-arm64.deb
|
||||
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
|
||||
sudo systemctl restart webui4frpc
|
||||
```
|
||||
@ -87,7 +87,7 @@ sudo systemctl restart webui4frpc
|
||||
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
|
||||
|
||||
```bash
|
||||
sudo rpm -i webui4frpc-0.1.4-linux-x86_64.rpm
|
||||
sudo rpm -i webui4frpc-0.2.0-linux-x86_64.rpm
|
||||
sudo vi /etc/webui4frpc/webui4frpc.env
|
||||
sudo systemctl enable --now webui4frpc
|
||||
```
|
||||
@ -97,7 +97,7 @@ sudo systemctl enable --now webui4frpc
|
||||
|
||||
### Windows
|
||||
|
||||
双击 `webui4frpc-0.1.4-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||
双击 `webui4frpc-0.2.0-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||
安装向导会询问监听地址与管理员凭据,完成后:
|
||||
|
||||
- 程序装到 `C:\Program Files\webui4frpc`
|
||||
@ -107,8 +107,8 @@ sudo systemctl enable --now webui4frpc
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
sudo installer -pkg webui4frpc-0.1.4-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.1.4-darwin-amd64.pkg -target / # Intel
|
||||
sudo installer -pkg webui4frpc-0.2.0-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.2.0-darwin-amd64.pkg -target / # Intel
|
||||
```
|
||||
|
||||
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
|
||||
@ -126,7 +126,7 @@ sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
|
||||
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
|
||||
|
||||
```bash
|
||||
tar xzf webui4frpc-0.1.4-linux-amd64.tar.gz
|
||||
tar xzf webui4frpc-0.2.0-linux-amd64.tar.gz
|
||||
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
|
||||
```
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.1.4}"
|
||||
VERSION="${1:-0.2.0}"
|
||||
VERSION="${VERSION#v}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="$ROOT/dist/artifacts/$VERSION"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
# webui4frpc 多平台发布脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/release.sh [version] # 默认 version=0.1.4
|
||||
# ./scripts/release.sh [version] # 默认 version=0.2.0
|
||||
#
|
||||
# 行为:
|
||||
# 1. 构建前端 (web/dist) 并同步到 internal/httpapi/dist (go:embed 用)
|
||||
@ -19,7 +19,7 @@
|
||||
# NO_BUILD_FRONTEND 设为 1 跳过前端构建 (用现有 dist)
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.1.4}"
|
||||
VERSION="${1:-0.2.0}"
|
||||
VERSION="${VERSION#v}" # 去掉可能的前导 v
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="$ROOT/dist/artifacts/$VERSION"
|
||||
|
||||
@ -51,6 +51,12 @@
|
||||
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
|
||||
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
|
||||
|
||||
<label class="adv-sep">用户标识 (frpc user)</label>
|
||||
<label>user <input v-model="userField" :placeholder="userHint" /></label>
|
||||
<div v-if="needsUser" class="user-warn">
|
||||
面板型 frps 必须填 user,否则登录被拒(panel HTTP status 403);留空则该项不出现在生成的配置里。
|
||||
</div>
|
||||
|
||||
<label class="adv-sep">本地 frpc 管理 API (M3)</label>
|
||||
<label class="adv-check">启用后状态页按真实代理状态展示
|
||||
<span class="hint-txt">(adminPort > 0)</span>
|
||||
@ -96,6 +102,7 @@ const portField = field('port')
|
||||
const tokenField = field('token')
|
||||
const urlField = field('url')
|
||||
const enabledField = field('enabled')
|
||||
const userField = field('user')
|
||||
const transportProtocolField = field('transportProtocol')
|
||||
const transportTlsField = field('transportTls')
|
||||
const transportTlsServerNameField = field('transportTlsServerName')
|
||||
@ -106,6 +113,13 @@ const adminPortField = field('adminPort')
|
||||
const adminUserField = field('adminUser')
|
||||
const adminPasswordField = field('adminPassword')
|
||||
const showAdv = ref(false)
|
||||
|
||||
// A panel-managed frps rejects password-only logins, so surface the field with
|
||||
// a hint instead of leaving the failure to a frpc log line nobody reads.
|
||||
const needsUser = computed(() => !String(userField.value || '').trim())
|
||||
const userHint = computed(() =>
|
||||
needsUser.value ? '必填(面板型 frps)' : '与 frps 侧一致',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -299,6 +313,14 @@ const showAdv = ref(false)
|
||||
border-top: 1px dashed var(--w4f-warn-border);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
// warning shown while the required `user` field is still empty
|
||||
.user-warn {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--w4f-warn-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -54,8 +54,14 @@ export interface Remote {
|
||||
// M1 transport section
|
||||
transportProtocol?: string; // tcp | quic | kcp | websocket
|
||||
transportTls?: boolean;
|
||||
transportPool?: number;
|
||||
transportTlsServerName?: string;
|
||||
transportPool?: number;
|
||||
transportTlsServerName?: string;
|
||||
|
||||
// user: frpc's global `user` field. frps prefixes every proxy name with it,
|
||||
// and panel-managed frps requires it for login (otherwise the server answers
|
||||
// `panel authentication unavailable: panel HTTP status 403`).
|
||||
// Empty = omitted from the rendered config (plain frps).
|
||||
user?: string;
|
||||
|
||||
// M2: frps vhostHTTPPort
|
||||
vhostHttpPort?: number;
|
||||
|
||||
@ -187,6 +187,10 @@
|
||||
<label>Token</label>
|
||||
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>user</label>
|
||||
<el-input v-model="dialog.remote.user" :disabled="!canWrite" placeholder="面板型 frps 必填,普通 frps 可留空" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>URL</label>
|
||||
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
|
||||
@ -226,6 +230,7 @@ const newRemote = (): Remote => ({
|
||||
port: 7000,
|
||||
token: '',
|
||||
url: '',
|
||||
user: '',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
@ -246,7 +251,7 @@ const openEdit = (p: { name: string }) => {
|
||||
dialog.isNew = false
|
||||
dialog.remote = found
|
||||
? { ...found }
|
||||
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
|
||||
: { name: p.name, ip: '', port: 7000, token: '', url: '', user: '', enabled: true }
|
||||
dialog.visible = true
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user