feat(handler/http,http2): support comma-separated hostnames in probeResistance knock

Knock previously accepted a single hostname. Now it accepts multiple
comma-separated hostnames; probe resistance is bypassed if the request
hostname matches any entry in the list. Matching is case-insensitive
and whitespace around entries is trimmed.
This commit is contained in:
ginuerzh
2026-05-28 23:20:27 +08:00
parent 3246b39c93
commit c246a1d4fa
6 changed files with 163 additions and 7 deletions
+110
View File
@@ -279,6 +279,116 @@ func TestAuthenticate_ProbeResistanceKnockMismatch(t *testing.T) {
}
}
func TestAuthenticate_ProbeResistanceKnockMultiMatch(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
// Third entry matches — probe resistance should be bypassed, return 407
h.md.probeResistance = &probeResistance{
Type: "code",
Value: "404",
Knock: "a.example.com, b.example.com, secret.example.com",
}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://secret.example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
defer r.Body.Close()
if r.StatusCode != http.StatusProxyAuthRequired {
t.Errorf("got status %d, want %d (knock matched, should bypass probe resistance)", r.StatusCode, http.StatusProxyAuthRequired)
}
}
func TestAuthenticate_ProbeResistanceKnockMultiMismatch(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
// No entry matches — probe resistance should activate
h.md.probeResistance = &probeResistance{
Type: "code",
Value: "503",
Knock: "a.example.com, b.example.com",
}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://other.example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
defer r.Body.Close()
if r.StatusCode != 503 {
t.Errorf("got status %d, want 503", r.StatusCode)
}
}
func TestKnockMatch(t *testing.T) {
tests := []struct {
hostname string
knock string
want bool
}{
{"", "", false},
{"example.com", "", false},
{"example.com", "example.com", true},
{"EXAMPLE.COM", "example.com", true}, // case-insensitive
{"example.com", "other.com", false},
{"example.com", " one.example.com , example.com , two.example.com ", true},
{"example.com", " one.example.com, two.example.com ", false},
{"", "example.com", false},
{"example.com", " example.com , , two.example.com ", true}, // empty entry between commas
}
for _, tt := range tests {
got := knockMatch(tt.hostname, tt.knock)
if got != tt.want {
t.Errorf("knockMatch(%q, %q) = %v, want %v", tt.hostname, tt.knock, got, tt.want)
}
}
}
func TestCheckRateLimit_NoLimiter(t *testing.T) {
h := &httpHandler{
options: handler.Options{},