proxy supports configuring annotations, which will be displayed in the frps dashboard (#4000)

This commit is contained in:
fatedier 2024-02-19 16:28:27 +08:00 committed by GitHub
parent 9152c59570
commit 3529158f31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 287 additions and 129 deletions

View File

@ -526,6 +526,8 @@ Check frp's status and proxies' statistics information by Dashboard.
Configure a port for dashboard to enable this feature: Configure a port for dashboard to enable this feature:
```toml ```toml
# The default value is 127.0.0.1. Change it to 0.0.0.0 when you want to access it from a public network.
webServer.addr = "0.0.0.0"
webServer.port = 7500 webServer.port = 7500
# dashboard's username and password are both optional # dashboard's username and password are both optional
webServer.user = "admin" webServer.user = "admin"
@ -534,8 +536,6 @@ webServer.password = "admin"
Then visit `http://[serverAddr]:7500` to see the dashboard, with username and password both being `admin`. Then visit `http://[serverAddr]:7500` to see the dashboard, with username and password both being `admin`.
Note that if you want your server to be accessed from public networks, then also add `webServer.addr = "0.0.0.0"` line. For security reasons (credits [#3709](https://github.com/fatedier/frp/issues/3709)), value `127.0.0.1` is used by default.
Additionally, you can use HTTPS port by using your domains wildcard or normal SSL certificate: Additionally, you can use HTTPS port by using your domains wildcard or normal SSL certificate:
```toml ```toml

View File

@ -1,11 +1,3 @@
### Deprecation Notices
* Using an underscore in a flag name is deprecated and has been replaced by a hyphen. The underscore format will remain compatible for some time, until it is completely removed in a future version. For example, `--remote_port` is replaced with `--remote-port`.
### Features ### Features
* The `Refresh` and `ClearOfflineProxies` buttons have been added to the Dashboard of frps. * Proxy supports configuring annotations, which will be displayed in the frps dashboard.
### Fixes
* The host/domain matching in the routing rules has been changed to be case-insensitive.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>frps dashboard</title> <title>frps dashboard</title>
<script type="module" crossorigin src="./index-1gecbKzv.js"></script> <script type="module" crossorigin src="./index-ky4re_ta.js"></script>
<link rel="stylesheet" crossorigin href="./index-Lf6B06jY.css"> <link rel="stylesheet" crossorigin href="./index-rzPDshRD.css">
</head> </head>
<body> <body>

View File

@ -164,11 +164,16 @@ healthCheck.type = "tcp"
healthCheck.timeoutSeconds = 3 healthCheck.timeoutSeconds = 3
# If continuous failed in 3 times, the proxy will be removed from frps # If continuous failed in 3 times, the proxy will be removed from frps
healthCheck.maxFailed = 3 healthCheck.maxFailed = 3
# every 10 seconds will do a health check # Every 10 seconds will do a health check
healthCheck.intervalSeconds = 10 healthCheck.intervalSeconds = 10
# additional meta info for each proxy # Additional meta info for each proxy. It will be passed to the server-side plugin for use.
metadatas.var1 = "abc" metadatas.var1 = "abc"
metadatas.var2 = "123" metadatas.var2 = "123"
# You can add some extra information to the proxy through annotations.
# These annotations will be displayed on the frps dashboard.
[proxies.annotations]
key1 = "value1"
"prefix/key2" = "value2"
[[proxies]] [[proxies]]
name = "ssh_random" name = "ssh_random"

3
go.mod
View File

@ -24,6 +24,7 @@ require (
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.17.1
golang.org/x/crypto v0.17.0 golang.org/x/crypto v0.17.0
golang.org/x/net v0.17.0 golang.org/x/net v0.17.0
golang.org/x/oauth2 v0.10.0 golang.org/x/oauth2 v0.10.0
@ -64,6 +65,8 @@ require (
github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect
github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect
golang.org/x/mod v0.10.0 // indirect golang.org/x/mod v0.10.0 // indirect

6
go.sum
View File

@ -145,6 +145,12 @@ github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 h1:89CEmDvlq/F7S
github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU= github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161/go.mod h1:wM7WEvslTq+iOEAMDLSzhVuOt5BRZ05WirO+b09GHQU=
github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b h1:fj5tQ8acgNUr6O8LEplsxDhUIe2573iLkJc+PqnzZTI= github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b h1:fj5tQ8acgNUr6O8LEplsxDhUIe2573iLkJc+PqnzZTI=
github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4= github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b/go.mod h1:5XA7W9S6mni3h5uvOC75dA3m9CCCaS83lltmc0ukdi4=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=

View File

@ -105,9 +105,10 @@ type DomainConfig struct {
} }
type ProxyBaseConfig struct { type ProxyBaseConfig struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Transport ProxyTransport `json:"transport,omitempty"` Annotations map[string]string `json:"annotations,omitempty"`
Transport ProxyTransport `json:"transport,omitempty"`
// metadata info for each proxy // metadata info for each proxy
Metadatas map[string]string `json:"metadatas,omitempty"` Metadatas map[string]string `json:"metadatas,omitempty"`
LoadBalancer LoadBalancerConfig `json:"loadBalancer,omitempty"` LoadBalancer LoadBalancerConfig `json:"loadBalancer,omitempty"`
@ -138,6 +139,7 @@ func (c *ProxyBaseConfig) MarshalToMsg(m *msg.NewProxy) {
m.Group = c.LoadBalancer.Group m.Group = c.LoadBalancer.Group
m.GroupKey = c.LoadBalancer.GroupKey m.GroupKey = c.LoadBalancer.GroupKey
m.Metas = c.Metadatas m.Metas = c.Metadatas
m.Annotations = c.Annotations
} }
func (c *ProxyBaseConfig) UnmarshalFromMsg(m *msg.NewProxy) { func (c *ProxyBaseConfig) UnmarshalFromMsg(m *msg.NewProxy) {
@ -154,6 +156,7 @@ func (c *ProxyBaseConfig) UnmarshalFromMsg(m *msg.NewProxy) {
c.LoadBalancer.Group = m.Group c.LoadBalancer.Group = m.Group
c.LoadBalancer.GroupKey = m.GroupKey c.LoadBalancer.GroupKey = m.GroupKey
c.Metadatas = m.Metas c.Metadatas = m.Metas
c.Annotations = m.Annotations
} }
type TypedProxyConfig struct { type TypedProxyConfig struct {

View File

@ -20,6 +20,7 @@ import (
"strings" "strings"
"github.com/samber/lo" "github.com/samber/lo"
"k8s.io/apimachinery/pkg/util/validation"
v1 "github.com/fatedier/frp/pkg/config/v1" v1 "github.com/fatedier/frp/pkg/config/v1"
) )
@ -29,10 +30,12 @@ func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
return errors.New("name should not be empty") return errors.New("name should not be empty")
} }
if err := ValidateAnnotations(c.Annotations); err != nil {
return err
}
if !lo.Contains([]string{"", "v1", "v2"}, c.Transport.ProxyProtocolVersion) { if !lo.Contains([]string{"", "v1", "v2"}, c.Transport.ProxyProtocolVersion) {
return fmt.Errorf("not support proxy protocol version: %s", c.Transport.ProxyProtocolVersion) return fmt.Errorf("not support proxy protocol version: %s", c.Transport.ProxyProtocolVersion)
} }
if !lo.Contains([]string{"client", "server"}, c.Transport.BandwidthLimitMode) { if !lo.Contains([]string{"client", "server"}, c.Transport.BandwidthLimitMode) {
return fmt.Errorf("bandwidth limit mode should be client or server") return fmt.Errorf("bandwidth limit mode should be client or server")
} }
@ -61,7 +64,10 @@ func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
return nil return nil
} }
func validateProxyBaseConfigForServer(c *v1.ProxyBaseConfig, s *v1.ServerConfig) error { func validateProxyBaseConfigForServer(c *v1.ProxyBaseConfig) error {
if err := ValidateAnnotations(c.Annotations); err != nil {
return err
}
return nil return nil
} }
@ -161,7 +167,7 @@ func validateSUDPProxyConfigForClient(c *v1.SUDPProxyConfig) error {
func ValidateProxyConfigurerForServer(c v1.ProxyConfigurer, s *v1.ServerConfig) error { func ValidateProxyConfigurerForServer(c v1.ProxyConfigurer, s *v1.ServerConfig) error {
base := c.GetBaseConfig() base := c.GetBaseConfig()
if err := validateProxyBaseConfigForServer(base, s); err != nil { if err := validateProxyBaseConfigForServer(base); err != nil {
return err return err
} }
@ -231,3 +237,34 @@ func validateXTCPProxyConfigForServer(c *v1.XTCPProxyConfig, s *v1.ServerConfig)
func validateSUDPProxyConfigForServer(c *v1.SUDPProxyConfig, s *v1.ServerConfig) error { func validateSUDPProxyConfigForServer(c *v1.SUDPProxyConfig, s *v1.ServerConfig) error {
return nil return nil
} }
// ValidateAnnotations validates that a set of annotations are correctly defined.
func ValidateAnnotations(annotations map[string]string) error {
if len(annotations) == 0 {
return nil
}
var errs error
for k := range annotations {
for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) {
errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg))
}
}
if err := ValidateAnnotationsSize(annotations); err != nil {
errs = AppendError(errs, err)
}
return errs
}
const TotalAnnotationSizeLimitB int = 256 * (1 << 10) // 256 kB
func ValidateAnnotationsSize(annotations map[string]string) error {
var totalSize int64
for k, v := range annotations {
totalSize += (int64)(len(k)) + (int64)(len(v))
}
if totalSize > (int64)(TotalAnnotationSizeLimitB) {
return fmt.Errorf("annotations size %d is larger than limit %d", totalSize, TotalAnnotationSizeLimitB)
}
return nil
}

View File

@ -108,6 +108,7 @@ type NewProxy struct {
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
GroupKey string `json:"group_key,omitempty"` GroupKey string `json:"group_key,omitempty"`
Metas map[string]string `json:"metas,omitempty"` Metas map[string]string `json:"metas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
// tcp and udp only // tcp and udp only
RemotePort int `json:"remote_port,omitempty"` RemotePort int `json:"remote_port,omitempty"`

View File

@ -0,0 +1,54 @@
package basic
import (
"fmt"
"io"
"net/http"
"github.com/onsi/ginkgo/v2"
"github.com/tidwall/gjson"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
)
var _ = ginkgo.Describe("[Feature: Annotations]", func() {
f := framework.NewDefaultFramework()
ginkgo.It("Set Proxy Annotations", func() {
webPort := f.AllocPort()
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
webServer.port = %d
`, webPort)
p1Port := f.AllocPort()
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
[[proxies]]
name = "p1"
type = "tcp"
localPort = {{ .%s }}
remotePort = %d
[proxies.annotations]
"frp.e2e.test/foo" = "value1"
"frp.e2e.test/bar" = "value2"
`, framework.TCPEchoServerPort, p1Port)
f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).Port(p1Port).Ensure()
// check annotations in frps
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/proxy/tcp/%s", webPort, "p1"))
framework.ExpectNoError(err)
framework.ExpectEqual(resp.StatusCode, 200)
defer resp.Body.Close()
content, err := io.ReadAll(resp.Body)
framework.ExpectNoError(err)
annotations := gjson.Get(string(content), "conf.annotations").Map()
framework.ExpectEqual("value1", annotations["frp.e2e.test/foo"].String())
framework.ExpectEqual("value2", annotations["frp.e2e.test/bar"].String())
})
})

View File

@ -9,19 +9,21 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
LongSpan: typeof import('./src/components/LongSpan.vue')['default'] LongSpan: typeof import('./src/components/LongSpan.vue')['default']
ProxiesHTTP: typeof import('./src/components/ProxiesHTTP.vue')['default'] ProxiesHTTP: typeof import('./src/components/ProxiesHTTP.vue')['default']

View File

@ -30,27 +30,6 @@
> >
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="props"> <template #default="props">
<el-popover
placement="right"
width="600"
style="margin-left: 0px"
trigger="click"
>
<template #default>
<Traffic :proxyName="props.row.name" />
</template>
<template #reference>
<el-button
type="primary"
size="large"
:name="props.row.name"
style="margin-bottom: 10px"
>Traffic Statistics
</el-button>
</template>
</el-popover>
<ProxyViewExpand :row="props.row" :proxyType="proxyType" /> <ProxyViewExpand :row="props.row" :proxyType="proxyType" />
</template> </template>
</el-table-column> </el-table-column>
@ -82,8 +61,27 @@
<el-tag v-else type="danger">{{ scope.row.status }}</el-tag> <el-tag v-else type="danger">{{ scope.row.status }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Operations">
<template #default="scope">
<el-button
type="primary"
:name="scope.row.name"
style="margin-bottom: 10px"
@click="dialogVisibleName = scope.row.name; dialogVisible = true"
>Traffic
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
<el-dialog
v-model="dialogVisible"
destroy-on-close="true"
:title="dialogVisibleName"
width="700px">
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -92,6 +90,7 @@ import type { TableColumnCtx } from 'element-plus'
import type { BaseProxy } from '../utils/proxy.js' import type { BaseProxy } from '../utils/proxy.js'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import ProxyViewExpand from './ProxyViewExpand.vue' import ProxyViewExpand from './ProxyViewExpand.vue'
import { ref } from 'vue'
defineProps<{ defineProps<{
proxies: BaseProxy[] proxies: BaseProxy[]
@ -100,6 +99,9 @@ defineProps<{
const emit = defineEmits(['refresh']) const emit = defineEmits(['refresh'])
const dialogVisible = ref(false)
const dialogVisibleName = ref("")
const formatTrafficIn = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => { const formatTrafficIn = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => {
return Humanize.fileSize(row.trafficIn) return Humanize.fileSize(row.trafficIn)
} }

View File

@ -60,11 +60,56 @@
<span>{{ row.lastCloseTime }}</span> <span>{{ row.lastCloseTime }}</span>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div v-if="row.annotations && row.annotations.size > 0">
<el-divider />
<el-text class="title-text" size="large">Annotations</el-text>
<ul>
<li v-for="item in annotationsArray()">
<span class="annotation-key">{{ item.key }}</span>
<span>{{ item.value }}</span>
</li>
</ul>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{
const props = defineProps<{
row: any row: any
proxyType: string proxyType: string
}>() }>()
// annotationsArray returns an array of key-value pairs from the annotations map.
const annotationsArray = (): Array<{ key: string; value: string }> => {
const array: Array<{ key: string; value: any }> = [];
if (props.row.annotations) {
props.row.annotations.forEach((value: any, key: string) => {
array.push({ key, value });
});
}
return array;
}
</script> </script>
<style>
ul {
list-style-type: none;
padding: 5px;
}
ul li {
justify-content: space-between;
padding: 5px;
}
ul .annotation-key {
width: 300px;
display: inline-block;
vertical-align: middle;
}
.title-text {
color: #99a9bf;
}
</style>

View File

@ -1,6 +1,7 @@
class BaseProxy { class BaseProxy {
name: string name: string
type: string type: string
annotations: Map<string, string>
encryption: boolean encryption: boolean
compression: boolean compression: boolean
conns: number conns: number
@ -21,6 +22,13 @@ class BaseProxy {
constructor(proxyStats: any) { constructor(proxyStats: any) {
this.name = proxyStats.name this.name = proxyStats.name
this.type = '' this.type = ''
this.annotations = new Map<string, string>()
if (proxyStats.conf?.annotations) {
for (const key in proxyStats.conf.annotations) {
this.annotations.set(key, proxyStats.conf.annotations[key])
}
}
this.encryption = false this.encryption = false
this.compression = false this.compression = false
this.encryption = this.encryption =