mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 17:29:16 +08:00
Compare commits
4 Commits
9bde0b07de
...
14628df63c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec |
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if connInfo.ProxyProtocolHeader != nil {
|
||||||
|
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||||
|
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||||
|
rawConn.Close()
|
||||||
|
tlsConn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
libio.Join(tlsConn, rawConn)
|
libio.Join(tlsConn, rawConn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
conn.PayloadType = websocket.BinaryFrame
|
||||||
return ctx, conn, nil
|
return ctx, conn, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
|||||||
|
|
||||||
muxer := http.NewServeMux()
|
muxer := http.NewServeMux()
|
||||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||||
|
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||||
|
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||||
|
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||||
|
// frames and close the connection on invalid bytes.
|
||||||
|
c.PayloadType = websocket.BinaryFrame
|
||||||
notifyCh := make(chan struct{})
|
notifyCh := make(chan struct{})
|
||||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||||
close(notifyCh)
|
close(notifyCh)
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
pp "github.com/pires/go-proxyproto"
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/transport"
|
"github.com/fatedier/frp/pkg/transport"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
"github.com/fatedier/frp/test/e2e/framework"
|
"github.com/fatedier/frp/test/e2e/framework"
|
||||||
"github.com/fatedier/frp/test/e2e/framework/consts"
|
"github.com/fatedier/frp/test/e2e/framework/consts"
|
||||||
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
"github.com/fatedier/frp/test/e2e/mock/server/httpserver"
|
||||||
|
"github.com/fatedier/frp/test/e2e/mock/server/streamserver"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
"github.com/fatedier/frp/test/e2e/pkg/cert"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/port"
|
"github.com/fatedier/frp/test/e2e/pkg/port"
|
||||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
|||||||
ExpectResp([]byte("test")).
|
ExpectResp([]byte("test")).
|
||||||
Ensure()
|
Ensure()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ginkgo.It("tls2raw with proxy protocol v2", func() {
|
||||||
|
generator := &cert.SelfSignedCertGenerator{}
|
||||||
|
artifacts, err := generator.Generate("example.com")
|
||||||
|
framework.ExpectNoError(err)
|
||||||
|
crtPath := f.WriteTempFile("tls2raw_proxy_protocol_server.crt", string(artifacts.Cert))
|
||||||
|
keyPath := f.WriteTempFile("tls2raw_proxy_protocol_server.key", string(artifacts.Key))
|
||||||
|
|
||||||
|
serverConf := consts.DefaultServerConfig
|
||||||
|
vhostHTTPSPort := f.AllocPort()
|
||||||
|
serverConf += fmt.Sprintf(`
|
||||||
|
vhostHTTPSPort = %d
|
||||||
|
`, vhostHTTPSPort)
|
||||||
|
|
||||||
|
localPort := f.AllocPort()
|
||||||
|
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
|
||||||
|
[[proxies]]
|
||||||
|
name = "tls2raw-proxy-protocol-test"
|
||||||
|
type = "https"
|
||||||
|
customDomains = ["example.com"]
|
||||||
|
transport.proxyProtocolVersion = "v2"
|
||||||
|
[proxies.plugin]
|
||||||
|
type = "tls2raw"
|
||||||
|
localAddr = "127.0.0.1:%d"
|
||||||
|
crtPath = "%s"
|
||||||
|
keyPath = "%s"
|
||||||
|
`, localPort, crtPath, keyPath)
|
||||||
|
|
||||||
|
f.RunProcesses(serverConf, []string{clientConf})
|
||||||
|
|
||||||
|
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
|
||||||
|
streamserver.WithCustomHandler(func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
writeResp := func(body string) {
|
||||||
|
_, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rd := bufio.NewReader(c)
|
||||||
|
ppHeader, err := pp.Read(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read proxy protocol error: %v", err)
|
||||||
|
writeResp("missing proxy protocol")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ppHeader.Version != 2 {
|
||||||
|
log.Errorf("unexpected proxy protocol version: %d", ppHeader.Version)
|
||||||
|
writeResp("unexpected proxy protocol version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
srcAddr, ok := ppHeader.SourceAddr.(*net.TCPAddr)
|
||||||
|
if !ok || srcAddr.IP.String() != "127.0.0.1" {
|
||||||
|
log.Errorf("unexpected proxy protocol source address: %v", ppHeader.SourceAddr)
|
||||||
|
writeResp("unexpected proxy protocol source address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.ReadRequest(rd)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("read http request after proxy protocol error: %v", err)
|
||||||
|
writeResp("missing http request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, req.Body)
|
||||||
|
_ = req.Body.Close()
|
||||||
|
|
||||||
|
writeResp("test")
|
||||||
|
}))
|
||||||
|
f.RunServer("", localServer)
|
||||||
|
|
||||||
|
framework.NewRequestExpect(f).
|
||||||
|
Port(vhostHTTPSPort).
|
||||||
|
RequestModify(func(r *request.Request) {
|
||||||
|
r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{
|
||||||
|
ServerName: "example.com",
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
})
|
||||||
|
}).
|
||||||
|
ExpectResp([]byte("test")).
|
||||||
|
Ensure()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,36 +27,6 @@
|
|||||||
clearable
|
clearable
|
||||||
class="main-search"
|
class="main-search"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PopoverMenu
|
|
||||||
:model-value="selectedClientKey"
|
|
||||||
:width="220"
|
|
||||||
placement="bottom-end"
|
|
||||||
selectable
|
|
||||||
filterable
|
|
||||||
filter-placeholder="Search clients..."
|
|
||||||
:display-value="selectedClientLabel"
|
|
||||||
clearable
|
|
||||||
class="client-filter"
|
|
||||||
@update:model-value="onClientFilterChange($event as string)"
|
|
||||||
>
|
|
||||||
<template #default="{ filterText }">
|
|
||||||
<PopoverMenuItem value="">All Clients</PopoverMenuItem>
|
|
||||||
<PopoverMenuItem
|
|
||||||
v-if="clientIDFilter && !selectedClientInList"
|
|
||||||
:value="selectedClientKey"
|
|
||||||
>
|
|
||||||
{{ userFilter ? userFilter + '.' : '' }}{{ clientIDFilter }} (not found)
|
|
||||||
</PopoverMenuItem>
|
|
||||||
<PopoverMenuItem
|
|
||||||
v-for="client in filteredClientOptions(filterText)"
|
|
||||||
:key="client.key"
|
|
||||||
:value="client.key"
|
|
||||||
>
|
|
||||||
{{ client.label }}
|
|
||||||
</PopoverMenuItem>
|
|
||||||
</template>
|
|
||||||
</PopoverMenu>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="type-tabs">
|
<div class="type-tabs">
|
||||||
@@ -111,7 +81,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElPagination } from 'element-plus'
|
import { ElMessage, ElPagination } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
@@ -128,15 +98,11 @@ import {
|
|||||||
SUDPProxy,
|
SUDPProxy,
|
||||||
} from '../utils/proxy'
|
} from '../utils/proxy'
|
||||||
import ProxyCard from '../components/ProxyCard.vue'
|
import ProxyCard from '../components/ProxyCard.vue'
|
||||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
|
||||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
|
||||||
import {
|
import {
|
||||||
getProxiesV2,
|
getProxiesV2,
|
||||||
clearOfflineProxies as apiClearOfflineProxies,
|
clearOfflineProxies as apiClearOfflineProxies,
|
||||||
} from '../api/proxy'
|
} from '../api/proxy'
|
||||||
import { getServerInfo } from '../api/server'
|
import { getServerInfo } from '../api/server'
|
||||||
import { getClientsV2 } from '../api/client'
|
|
||||||
import { Client } from '../utils/client'
|
|
||||||
import type { ProxyStatsInfo } from '../types/proxy'
|
import type { ProxyStatsInfo } from '../types/proxy'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -156,102 +122,15 @@ const proxyTypes = [
|
|||||||
|
|
||||||
const activeType = ref((route.params.type as string) || 'tcp')
|
const activeType = ref((route.params.type as string) || 'tcp')
|
||||||
const proxies = ref<BaseProxy[]>([])
|
const proxies = ref<BaseProxy[]>([])
|
||||||
const clients = ref<Client[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const showClearDialog = ref(false)
|
const showClearDialog = ref(false)
|
||||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
|
||||||
const userFilter = ref((route.query.user as string) || '')
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const maxV2PageSize = 100
|
|
||||||
let requestSeq = 0
|
let requestSeq = 0
|
||||||
let searchDebounceTimer: number | null = null
|
let searchDebounceTimer: number | null = null
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
|
||||||
return clients.value
|
|
||||||
.map((c) => ({
|
|
||||||
key: c.key,
|
|
||||||
clientID: c.clientID,
|
|
||||||
user: c.user,
|
|
||||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Compute selected client key for el-select v-model
|
|
||||||
const selectedClientKey = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return ''
|
|
||||||
const client = clientOptions.value.find(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
|
||||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedClientLabel = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return 'All Clients'
|
|
||||||
const client = clientOptions.value.find(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
return client?.label || `${userFilter.value ? userFilter.value + '.' : ''}${clientIDFilter.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredClientOptions = (filterText: string) => {
|
|
||||||
if (!filterText) return clientOptions.value
|
|
||||||
const search = filterText.toLowerCase()
|
|
||||||
return clientOptions.value.filter((c) => c.label.toLowerCase().includes(search))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the filtered client exists in the client list
|
|
||||||
const selectedClientInList = computed(() => {
|
|
||||||
if (!clientIDFilter.value) return true
|
|
||||||
return clientOptions.value.some(
|
|
||||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const onClientFilterChange = (key: string) => {
|
|
||||||
if (key) {
|
|
||||||
const client = clientOptions.value.find((c) => c.key === key)
|
|
||||||
if (client) {
|
|
||||||
router.replace({
|
|
||||||
query: { ...route.query, clientID: client.clientID, user: client.user },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const query = { ...route.query }
|
|
||||||
delete query.clientID
|
|
||||||
delete query.user
|
|
||||||
router.replace({ query })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchClients = async () => {
|
|
||||||
try {
|
|
||||||
const allClients: Client[] = []
|
|
||||||
let nextPage = 1
|
|
||||||
let totalClients = 0
|
|
||||||
|
|
||||||
do {
|
|
||||||
const data = await getClientsV2({
|
|
||||||
page: nextPage,
|
|
||||||
pageSize: maxV2PageSize,
|
|
||||||
})
|
|
||||||
allClients.push(...data.items.map((item) => new Client(item)))
|
|
||||||
totalClients = data.total
|
|
||||||
nextPage += 1
|
|
||||||
} while (allClients.length < totalClients)
|
|
||||||
|
|
||||||
clients.value = allClients
|
|
||||||
} catch (err) {
|
|
||||||
// Client dropdown is a non-critical side load; log for diagnostics
|
|
||||||
// but don't surface a toast (would compete with the main fetch error).
|
|
||||||
console.warn('Failed to fetch clients for filter:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server info cache - cache the Promise itself so concurrent first calls
|
// Server info cache - cache the Promise itself so concurrent first calls
|
||||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||||
type ServerInfoLite = {
|
type ServerInfoLite = {
|
||||||
@@ -337,8 +216,6 @@ const fetchData = async (silent = false) => {
|
|||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
type: activeType.value === 'all' ? undefined : activeType.value,
|
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
clientID: clientIDFilter.value || undefined,
|
|
||||||
user: clientIDFilter.value ? userFilter.value : undefined,
|
|
||||||
})
|
})
|
||||||
if (seq !== requestSeq) return
|
if (seq !== requestSeq) return
|
||||||
|
|
||||||
@@ -419,6 +296,18 @@ const clearOfflineProxies = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sanitizeClientQuery = () => {
|
||||||
|
const hasClientQuery =
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'clientID') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(route.query, 'user')
|
||||||
|
if (!hasClientQuery) return
|
||||||
|
|
||||||
|
const query = { ...route.query }
|
||||||
|
delete query.clientID
|
||||||
|
delete query.user
|
||||||
|
router.replace({ query })
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for type changes
|
// Watch for type changes
|
||||||
watch(activeType, (newType) => {
|
watch(activeType, (newType) => {
|
||||||
clearSearchDebounce()
|
clearSearchDebounce()
|
||||||
@@ -437,23 +326,15 @@ watch(searchText, () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for route query changes (client filter)
|
watch(() => route.query, sanitizeClientQuery)
|
||||||
watch(
|
|
||||||
() => [route.query.clientID, route.query.user],
|
|
||||||
([newClientID, newUser]) => {
|
|
||||||
clientIDFilter.value = (newClientID as string) || ''
|
|
||||||
userFilter.value = (newUser as string) || ''
|
|
||||||
resetPageAndFetch()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearSearchDebounce()
|
clearSearchDebounce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
|
sanitizeClientQuery()
|
||||||
fetchData()
|
fetchData()
|
||||||
fetchClients()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -520,16 +401,11 @@ fetchClients()
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-search :deep(.el-input__wrapper),
|
.main-search :deep(.el-input__wrapper) {
|
||||||
.client-filter :deep(.el-input__wrapper) {
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
|
||||||
width: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-tabs {
|
.type-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -584,10 +460,6 @@ fetchClients()
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-filter {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-section {
|
.pagination-section {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user