No proxies found
+Proxies will appear here once configured and connected.
+Store is not enabled. Add the following to your frpc configuration:
+[store] +path = "./frpc_store.json"+
No store proxies
+Click "New Proxy" to create one.
+diff --git a/.gitignore b/.gitignore index 47fb34ad..161c114e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ client.key # AI .claude/ .sisyphus/ +.superpowers/ diff --git a/client/api_router.go b/client/api_router.go index ceab4fbc..82b73046 100644 --- a/client/api_router.go +++ b/client/api_router.go @@ -38,6 +38,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet) subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut) + subRouter.HandleFunc("/api/proxy/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetProxyConfig)).Methods(http.MethodGet) + subRouter.HandleFunc("/api/visitor/{name}/config", httppkg.MakeHTTPHandlerFunc(apiController.GetVisitorConfig)).Methods(http.MethodGet) if svr.storeSource != nil { subRouter.HandleFunc("/api/store/proxies", httppkg.MakeHTTPHandlerFunc(apiController.ListStoreProxies)).Methods(http.MethodGet) diff --git a/client/config_manager.go b/client/config_manager.go index 1d3fb0ec..24512e9f 100644 --- a/client/config_manager.go +++ b/client/config_manager.go @@ -80,6 +80,48 @@ func (m *serviceConfigManager) GetProxyStatus() []*proxy.WorkingStatus { return m.svr.getAllProxyStatus() } +func (m *serviceConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { + // Try running proxy manager first + ws, ok := m.svr.getProxyStatus(name) + if ok { + return ws.Cfg, true + } + + // Fallback to store + m.svr.reloadMu.Lock() + storeSource := m.svr.storeSource + m.svr.reloadMu.Unlock() + + if storeSource != nil { + cfg := storeSource.GetProxy(name) + if cfg != nil { + return cfg, true + } + } + return nil, false +} + +func (m *serviceConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { + // Try running visitor manager first + cfg, ok := m.svr.getVisitorCfg(name) + if ok { + return cfg, true + } + + // Fallback to store + m.svr.reloadMu.Lock() + storeSource := m.svr.storeSource + m.svr.reloadMu.Unlock() + + if storeSource != nil { + vcfg := storeSource.GetVisitor(name) + if vcfg != nil { + return vcfg, true + } + } + return nil, false +} + func (m *serviceConfigManager) IsStoreProxyEnabled(name string) bool { if name == "" { return false diff --git a/client/configmgmt/types.go b/client/configmgmt/types.go index 090d0b7b..5a51a5e5 100644 --- a/client/configmgmt/types.go +++ b/client/configmgmt/types.go @@ -26,6 +26,9 @@ type ConfigManager interface { IsStoreProxyEnabled(name string) bool StoreEnabled() bool + GetProxyConfig(name string) (v1.ProxyConfigurer, bool) + GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) + ListStoreProxies() ([]v1.ProxyConfigurer, error) GetStoreProxy(name string) (v1.ProxyConfigurer, error) CreateStoreProxy(cfg v1.ProxyConfigurer) (v1.ProxyConfigurer, error) diff --git a/client/http/controller.go b/client/http/controller.go index d06396dc..57e8165d 100644 --- a/client/http/controller.go +++ b/client/http/controller.go @@ -162,6 +162,44 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro return psr } +// GetProxyConfig handles GET /api/proxy/{name}/config +func (c *Controller) GetProxyConfig(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "proxy name is required") + } + + cfg, ok := c.manager.GetProxyConfig(name) + if !ok { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("proxy %q not found", name)) + } + + payload, err := model.ProxyDefinitionFromConfigurer(cfg) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return payload, nil +} + +// GetVisitorConfig handles GET /api/visitor/{name}/config +func (c *Controller) GetVisitorConfig(ctx *httppkg.Context) (any, error) { + name := ctx.Param("name") + if name == "" { + return nil, httppkg.NewError(http.StatusBadRequest, "visitor name is required") + } + + cfg, ok := c.manager.GetVisitorConfig(name) + if !ok { + return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("visitor %q not found", name)) + } + + payload, err := model.VisitorDefinitionFromConfigurer(cfg) + if err != nil { + return nil, httppkg.NewError(http.StatusInternalServerError, err.Error()) + } + return payload, nil +} + func (c *Controller) ListStoreProxies(ctx *httppkg.Context) (any, error) { proxies, err := c.manager.ListStoreProxies() if err != nil { diff --git a/client/http/controller_test.go b/client/http/controller_test.go index aa88c545..719fbcd4 100644 --- a/client/http/controller_test.go +++ b/client/http/controller_test.go @@ -26,6 +26,8 @@ type fakeConfigManager struct { getProxyStatusFn func() []*proxy.WorkingStatus isStoreProxyEnabledFn func(name string) bool storeEnabledFn func() bool + getProxyConfigFn func(name string) (v1.ProxyConfigurer, bool) + getVisitorConfigFn func(name string) (v1.VisitorConfigurer, bool) listStoreProxiesFn func() ([]v1.ProxyConfigurer, error) getStoreProxyFn func(name string) (v1.ProxyConfigurer, error) @@ -82,6 +84,20 @@ func (m *fakeConfigManager) StoreEnabled() bool { return false } +func (m *fakeConfigManager) GetProxyConfig(name string) (v1.ProxyConfigurer, bool) { + if m.getProxyConfigFn != nil { + return m.getProxyConfigFn(name) + } + return nil, false +} + +func (m *fakeConfigManager) GetVisitorConfig(name string) (v1.VisitorConfigurer, bool) { + if m.getVisitorConfigFn != nil { + return m.getVisitorConfigFn(name) + } + return nil, false +} + func (m *fakeConfigManager) ListStoreProxies() ([]v1.ProxyConfigurer, error) { if m.listStoreProxiesFn != nil { return m.listStoreProxiesFn() @@ -529,3 +545,118 @@ func TestUpdateStoreProxyReturnsTypedPayload(t *testing.T) { t.Fatalf("unexpected response payload: %#v", payload) } } + +func TestGetProxyConfigFromManager(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { + if name == "ssh" { + cfg := &v1.TCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: "ssh", + Type: "tcp", + ProxyBackend: v1.ProxyBackend{ + LocalPort: 22, + }, + }, + } + return cfg, true + } + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/proxy/ssh/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "ssh"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.GetProxyConfig(ctx) + if err != nil { + t.Fatalf("get proxy config: %v", err) + } + payload, ok := resp.(model.ProxyDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Name != "ssh" || payload.Type != "tcp" || payload.TCP == nil { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestGetProxyConfigNotFound(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getProxyConfigFn: func(name string) (v1.ProxyConfigurer, bool) { + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/proxy/missing/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "missing"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.GetProxyConfig(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusNotFound) +} + +func TestGetVisitorConfigFromManager(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { + if name == "my-stcp" { + cfg := &v1.STCPVisitorConfig{ + VisitorBaseConfig: v1.VisitorBaseConfig{ + Name: "my-stcp", + Type: "stcp", + ServerName: "server1", + BindPort: 9000, + }, + } + return cfg, true + } + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/visitor/my-stcp/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "my-stcp"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + resp, err := controller.GetVisitorConfig(ctx) + if err != nil { + t.Fatalf("get visitor config: %v", err) + } + payload, ok := resp.(model.VisitorDefinition) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + if payload.Name != "my-stcp" || payload.Type != "stcp" || payload.STCP == nil { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestGetVisitorConfigNotFound(t *testing.T) { + controller := &Controller{ + manager: &fakeConfigManager{ + getVisitorConfigFn: func(name string) (v1.VisitorConfigurer, bool) { + return nil, false + }, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/visitor/missing/config", nil) + req = mux.SetURLVars(req, map[string]string{"name": "missing"}) + ctx := httppkg.NewContext(httptest.NewRecorder(), req) + + _, err := controller.GetVisitorConfig(ctx) + if err == nil { + t.Fatal("expected error") + } + assertHTTPCode(t, err, http.StatusNotFound) +} diff --git a/client/service.go b/client/service.go index f419b068..5c51fd67 100644 --- a/client/service.go +++ b/client/service.go @@ -521,6 +521,17 @@ func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) { return ctl.pm.GetProxyStatus(name) } +func (svr *Service) getVisitorCfg(name string) (v1.VisitorConfigurer, bool) { + svr.ctlMu.RLock() + ctl := svr.ctl + svr.ctlMu.RUnlock() + + if ctl == nil { + return nil, false + } + return ctl.vm.GetVisitorCfg(name) +} + func (svr *Service) StatusExporter() StatusExporter { return &statusExporterImpl{ getProxyStatusFunc: svr.getProxyStatus, diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go index b3539c69..b60f7047 100644 --- a/client/visitor/visitor_manager.go +++ b/client/visitor/visitor_manager.go @@ -191,6 +191,13 @@ func (vm *Manager) TransferConn(name string, conn net.Conn) error { return v.AcceptConn(conn) } +func (vm *Manager) GetVisitorCfg(name string) (v1.VisitorConfigurer, bool) { + vm.mu.RLock() + defer vm.mu.RUnlock() + cfg, ok := vm.cfgs[name] + return cfg, ok +} + type visitorHelperImpl struct { connectServerFn func() (net.Conn, error) msgTransporter transport.MessageTransporter diff --git a/web/frpc/components.d.ts b/web/frpc/components.d.ts index 700f3c42..4236f460 100644 --- a/web/frpc/components.d.ts +++ b/web/frpc/components.d.ts @@ -7,28 +7,45 @@ export {} declare module 'vue' { export interface GlobalComponents { - ElButton: typeof import('element-plus/es')['ElButton'] - ElCard: typeof import('element-plus/es')['ElCard'] - ElCol: typeof import('element-plus/es')['ElCol'] - ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition'] + ActionButton: typeof import('./src/components/ActionButton.vue')['default'] + BaseDialog: typeof import('./src/components/BaseDialog.vue')['default'] + ConfigField: typeof import('./src/components/ConfigField.vue')['default'] + ConfigSection: typeof import('./src/components/ConfigSection.vue')['default'] + ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default'] + ElDialog: typeof import('element-plus/es')['ElDialog'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] - ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] - ElOption: typeof import('element-plus/es')['ElOption'] + ElPopover: typeof import('element-plus/es')['ElPopover'] ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] - ElRow: typeof import('element-plus/es')['ElRow'] - ElSelect: typeof import('element-plus/es')['ElSelect'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] - ElTag: typeof import('element-plus/es')['ElTag'] - ElTooltip: typeof import('element-plus/es')['ElTooltip'] + FilterDropdown: typeof import('./src/components/FilterDropdown.vue')['default'] KeyValueEditor: typeof import('./src/components/KeyValueEditor.vue')['default'] + PopoverMenu: typeof import('./src/components/PopoverMenu.vue')['default'] + PopoverMenuItem: typeof import('./src/components/PopoverMenuItem.vue')['default'] + ProxyAuthSection: typeof import('./src/components/proxy-form/ProxyAuthSection.vue')['default'] + ProxyBackendSection: typeof import('./src/components/proxy-form/ProxyBackendSection.vue')['default'] + ProxyBaseSection: typeof import('./src/components/proxy-form/ProxyBaseSection.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] + ProxyFormLayout: typeof import('./src/components/proxy-form/ProxyFormLayout.vue')['default'] + ProxyHealthSection: typeof import('./src/components/proxy-form/ProxyHealthSection.vue')['default'] + ProxyHttpSection: typeof import('./src/components/proxy-form/ProxyHttpSection.vue')['default'] + ProxyLoadBalanceSection: typeof import('./src/components/proxy-form/ProxyLoadBalanceSection.vue')['default'] + ProxyMetadataSection: typeof import('./src/components/proxy-form/ProxyMetadataSection.vue')['default'] + ProxyNatSection: typeof import('./src/components/proxy-form/ProxyNatSection.vue')['default'] + ProxyRemoteSection: typeof import('./src/components/proxy-form/ProxyRemoteSection.vue')['default'] + ProxyTransportSection: typeof import('./src/components/proxy-form/ProxyTransportSection.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - StatCard: typeof import('./src/components/StatCard.vue')['default'] + StatusPills: typeof import('./src/components/StatusPills.vue')['default'] + StringListEditor: typeof import('./src/components/StringListEditor.vue')['default'] + VisitorBaseSection: typeof import('./src/components/visitor-form/VisitorBaseSection.vue')['default'] + VisitorConnectionSection: typeof import('./src/components/visitor-form/VisitorConnectionSection.vue')['default'] + VisitorFormLayout: typeof import('./src/components/visitor-form/VisitorFormLayout.vue')['default'] + VisitorTransportSection: typeof import('./src/components/visitor-form/VisitorTransportSection.vue')['default'] + VisitorXtcpSection: typeof import('./src/components/visitor-form/VisitorXtcpSection.vue')['default'] } export interface ComponentCustomProperties { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/web/frpc/index.html b/web/frpc/index.html index 0c7caa7b..a893c9c1 100644 --- a/web/frpc/index.html +++ b/web/frpc/index.html @@ -3,6 +3,7 @@
+- Edit and manage your frpc configuration file -
+serverAddr
- Server address
- serverPort
- Server port (default: 7000)
- auth.token
- Authentication token
- -[[proxies]] -name = "web" -type = "http" -localPort = 80 -customDomains = ["example.com"]-
No proxies configured
-- Add proxies in your configuration file or use Store to create - dynamic proxies -
-- Proxies from Store are marked with a purple indicator -
- - -- Enable Store in your configuration to dynamically manage proxies -
- -- Edit a proxy and enable it to make it active again. -
-No visitors configured
-- Create your first visitor to connect to secure proxies. -
-+ Source: {{ displaySource }} · Type: + {{ proxy.type.toUpperCase() }} +
+Proxy not found
+The proxy "{{ proxyName }}" does not exist.
+No proxies found
+Proxies will appear here once configured and connected.
+Store is not enabled. Add the following to your frpc configuration:
+[store] +path = "./frpc_store.json"+
No store proxies
+Click "New Proxy" to create one.
+Type: {{ visitor.type.toUpperCase() }}
+Visitor not found
+The visitor "{{ visitorName }}" does not exist.
+Store is not enabled. Add the following to your frpc configuration:
+[store] +path = "./frpc_store.json"+
No visitors found
+Click "New Visitor" to create one.
+