mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 09:19:17 +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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
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{})
|
||||
conn := WrapCloseNotifyConn(c, func(_ error) {
|
||||
close(notifyCh)
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
pp "github.com/pires/go-proxyproto"
|
||||
|
||||
"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/consts"
|
||||
"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/port"
|
||||
"github.com/fatedier/frp/test/e2e/pkg/request"
|
||||
@@ -450,4 +456,85 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() {
|
||||
ExpectResp([]byte("test")).
|
||||
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
|
||||
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 class="type-tabs">
|
||||
@@ -111,7 +81,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElPagination } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
@@ -128,15 +98,11 @@ import {
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
import PopoverMenu from '@shared/components/PopoverMenu.vue'
|
||||
import PopoverMenuItem from '@shared/components/PopoverMenuItem.vue'
|
||||
import {
|
||||
getProxiesV2,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import { getClientsV2 } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
import type { ProxyStatsInfo } from '../types/proxy'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -156,102 +122,15 @@ const proxyTypes = [
|
||||
|
||||
const activeType = ref((route.params.type as string) || 'tcp')
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
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 pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const maxV2PageSize = 100
|
||||
let requestSeq = 0
|
||||
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
|
||||
// from Promise.all (convertProxies) don't kick off multiple HTTP requests.
|
||||
type ServerInfoLite = {
|
||||
@@ -337,8 +216,6 @@ const fetchData = async (silent = false) => {
|
||||
pageSize: pageSize.value,
|
||||
type: activeType.value === 'all' ? undefined : activeType.value,
|
||||
q: q || undefined,
|
||||
clientID: clientIDFilter.value || undefined,
|
||||
user: clientIDFilter.value ? userFilter.value : undefined,
|
||||
})
|
||||
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(activeType, (newType) => {
|
||||
clearSearchDebounce()
|
||||
@@ -437,23 +326,15 @@ watch(searchText, () => {
|
||||
}, 300)
|
||||
})
|
||||
|
||||
// Watch for route query changes (client filter)
|
||||
watch(
|
||||
() => [route.query.clientID, route.query.user],
|
||||
([newClientID, newUser]) => {
|
||||
clientIDFilter.value = (newClientID as string) || ''
|
||||
userFilter.value = (newUser as string) || ''
|
||||
resetPageAndFetch()
|
||||
},
|
||||
)
|
||||
watch(() => route.query, sanitizeClientQuery)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearSearchDebounce()
|
||||
})
|
||||
|
||||
// Initial fetch
|
||||
sanitizeClientQuery()
|
||||
fetchData()
|
||||
fetchClients()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -520,16 +401,11 @@ fetchClients()
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper),
|
||||
.client-filter :deep(.el-input__wrapper) {
|
||||
.main-search :deep(.el-input__wrapper) {
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -584,10 +460,6 @@ fetchClients()
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user