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:
+19
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user