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
+19 -4
View File
@@ -26,9 +26,9 @@ import (
// - "web": fetch a URL and replay its response as the decoy.
// - "host": forward the raw request to a decoy host and relay the reply.
// - "file": serve a local file with Content-Type: text/html.
// - knock: when pr.Knock is set, probe resistance only activates if the
// request hostname does NOT match the knock address. Clients that know
// the knock hostname get the normal 407 Proxy-Auth-Required.
// - knock: when pr.Knock is set (comma-separated hostnames), probe resistance
// only activates if the request hostname matches NONE of the knock addresses.
// Clients that know any knock hostname get the normal 407 Proxy-Auth-Required.
//
// On success it returns the authenticated client ID. On failure it writes
// the response (407 or probe-resistance decoy) to conn and returns ok=false.
@@ -49,7 +49,7 @@ func (h *httpHandler) authenticate(ctx context.Context, conn net.Conn, req *http
// When pr.Knock matches the hostname, probe resistance is bypassed and
// the normal 407 Proxy-Auth-Required is returned, revealing the proxy
// only to clients that know the knock address.
if pr != nil && (pr.Knock == "" || !strings.EqualFold(req.URL.Hostname(), pr.Knock)) {
if pr != nil && (pr.Knock == "" || !knockMatch(req.URL.Hostname(), pr.Knock)) {
resp.StatusCode = http.StatusServiceUnavailable
switch pr.Type {
@@ -159,3 +159,18 @@ func (h *httpHandler) checkRateLimit(addr net.Addr) bool {
return true
}
// knockMatch reports whether hostname matches any entry in the
// comma-separated knock list. Matching is case-insensitive. An empty
// knock string returns false (no match).
func knockMatch(hostname, knock string) bool {
if knock == "" {
return false
}
for _, h := range strings.Split(knock, ",") {
if strings.EqualFold(hostname, strings.TrimSpace(h)) {
return true
}
}
return false
}
+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{},
+1 -1
View File
@@ -148,5 +148,5 @@ func (h *httpHandler) parseMetadata(md mdata.Metadata) error {
type probeResistance struct {
Type string // strategy: "code", "web", "host", or "file"
Value string // strategy-specific parameter (status code, URL, address, path)
Knock string // optional hostname; when set, probe resistance only fires for non-matching hosts
Knock string // optional comma-separated hostnames; probe resistance only fires when the request hostname matches none of them
}
+16
View File
@@ -117,6 +117,22 @@ func TestParseMetadata_ProbeResistance(t *testing.T) {
}
}
func TestParseMetadata_ProbeResistance_KnockMulti(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"probeResist": "code:404",
"knock": " a.example.com , b.example.com ",
})); err != nil {
t.Fatal(err)
}
if h.md.probeResistance == nil {
t.Fatal("expected probeResistance to be set")
}
if h.md.probeResistance.Knock != " a.example.com , b.example.com " {
t.Errorf("got knock %q, want raw comma-separated string", h.md.probeResistance.Knock)
}
}
func TestParseMetadata_ProbeResistance_InvalidFormat(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"probeResist": "invalid"})); err != nil {
+16 -1
View File
@@ -391,7 +391,7 @@ func (h *http2Handler) authenticate(ctx context.Context, w http.ResponseWriter,
pr := h.md.probeResistance
// probing resistance is enabled, and knocking host is mismatch.
if pr != nil && (pr.Knock == "" || !strings.EqualFold(r.URL.Hostname(), pr.Knock)) {
if pr != nil && (pr.Knock == "" || !knockMatch(r.URL.Hostname(), pr.Knock)) {
resp.StatusCode = http.StatusServiceUnavailable // default status code
switch pr.Type {
case "code":
@@ -536,3 +536,18 @@ func (h *http2Handler) observeStats(ctx context.Context) {
}
}
}
// knockMatch reports whether hostname matches any entry in the
// comma-separated knock list. Matching is case-insensitive. An empty
// knock string returns false (no match).
func knockMatch(hostname, knock string) bool {
if knock == "" {
return false
}
for _, h := range strings.Split(knock, ",") {
if strings.EqualFold(hostname, strings.TrimSpace(h)) {
return true
}
}
return false
}
+1 -1
View File
@@ -64,5 +64,5 @@ func (h *http2Handler) parseMetadata(md mdata.Metadata) error {
type probeResistance struct {
Type string
Value string
Knock string
Knock string // optional comma-separated hostnames; probe resistance only fires when the request hostname matches none of them
}