This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 8bfdef573 fix(rmqctl): accept scoped IPv6 link-local Studio endpoints
(#7135)
8bfdef573 is described below
commit 8bfdef573f292665c8216597ec64af6b7d5d2552
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 20:34:09 2026 +0800
fix(rmqctl): accept scoped IPv6 link-local Studio endpoints (#7135)
* fix(rmqctl): accept scoped IPv6 link-local Studio endpoints (#7131)
url.Parse reports the hostname of an escaped scoped literal such as
http://[fe80::1%25eth0]:6789 as fe80::1%eth0, zone included. isPrivateHost
classified that host with net.ParseIP, which rejects the "%eth0" suffix, so
every scoped loopback, link-local or private IPv6 endpoint was treated as a
public host and plain HTTP was refused before any request was dispatched.
Classify the host with net/netip instead: it keeps the zone and lets the
address be judged by its actual bits, so scoped loopback, link-local and
RFC1918/ULA addresses are accepted like their unscoped forms. IPv4-mapped
addresses are unmapped to stay classified like their IPv4 form. The zone is
left untouched in the outgoing URL (still escaped as "%25eth0"), and public
or malformed hosts are still rejected.
---
rmqctl/internal/studio/auth.go | 26 ++++++++---
rmqctl/internal/studio/client_test.go | 84 +++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+), 6 deletions(-)
diff --git a/rmqctl/internal/studio/auth.go b/rmqctl/internal/studio/auth.go
index fa234324f..394fa0ba8 100644
--- a/rmqctl/internal/studio/auth.go
+++ b/rmqctl/internal/studio/auth.go
@@ -11,8 +11,8 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
- "net"
"net/http"
+ "net/netip"
"strconv"
"strings"
"time"
@@ -115,8 +115,22 @@ func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
- ip := net.ParseIP(host)
- return ip != nil && ip.IsLoopback()
+ addr, ok := parseHostAddress(host)
+ return ok && addr.IsLoopback()
+}
+
+// parseHostAddress parses host as an IP literal while honouring an IPv6 zone,
+// such as the "eth0" in "fe80::1%eth0" (the hostname url.Parse reports for the
+// escaped "[fe80::1%25eth0]" form). net.ParseIP rejects the zone suffix, so
+// every scoped link-local endpoint used to be classified as a public host.
+// IPv4-mapped IPv6 addresses are unmapped so that they keep being classified
+// exactly like the equivalent IPv4 literal.
+func parseHostAddress(host string) (netip.Addr, bool) {
+ addr, err := netip.ParseAddr(host)
+ if err != nil {
+ return netip.Addr{}, false
+ }
+ return addr.Unmap(), true
}
// isPrivateHost reports whether host is a loopback, RFC1918 private-network,
@@ -128,9 +142,9 @@ func isPrivateHost(host string) bool {
if isLoopbackHost(host) {
return true
}
- ip := net.ParseIP(host)
- if ip == nil {
+ addr, ok := parseHostAddress(host)
+ if !ok {
return false
}
- return ip.IsPrivate() || ip.IsLinkLocalUnicast()
+ return addr.IsPrivate() || addr.IsLinkLocalUnicast()
}
diff --git a/rmqctl/internal/studio/client_test.go
b/rmqctl/internal/studio/client_test.go
index 4334d6d10..e8302e631 100644
--- a/rmqctl/internal/studio/client_test.go
+++ b/rmqctl/internal/studio/client_test.go
@@ -19,6 +19,7 @@ package studio
import (
"context"
"encoding/json"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -115,3 +116,86 @@ func
TestRequestPreservesLargeIntegersInPassthroughPayload(t *testing.T) {
t.Fatalf("payload lost integer precision: %s", encoded)
}
}
+
+// A scoped IPv6 literal such as [fe80::1%25eth0] is a valid Studio endpoint:
+// url.Parse decodes the "%25" escape and reports the hostname "fe80::1%eth0",
+// zone included. net.ParseIP rejects that zone suffix, so scoped loopback,
+// link-local and private addresses were misclassified as public hosts and
plain
+// HTTP was refused. Classification must parse the scoped address as-is, while
+// the zone is preserved in the outgoing URL so the kernel can pick an
interface.
+func TestValidateServerSchemeScopedIPv6(t *testing.T) {
+ tests := []struct {
+ name string
+ server string
+ wantErr string
+ }{
+ {"http link-local named zone", "http://[fe80::1%25eth0]:6789",
""},
+ {"http link-local numeric zone", "http://[fe80::1%252]:6789",
""},
+ {"http loopback named zone", "http://[::1%25lo]:6789", ""},
+ {"http private ula named zone", "http://[fd00::1%25eth0]:6789",
""},
+ {"https link-local named zone",
"https://[fe80::1%25eth0]:6789", ""},
+ {"http public ipv6 with zone rejected",
"http://[2001:db8::1%25eth0]:6789", "must use HTTPS"},
+ {"http public ipv6 rejected", "http://[2001:db8::1]:6789",
"must use HTTPS"},
+ {"empty zone rejected", "http://[fe80::1%25]:6789", "invalid
Studio Server URL"},
+ {"malformed ipv6 rejected", "http://[zz::1%25eth0]:6789",
"invalid Studio Server URL"},
+ {"missing bracket rejected", "http://[fe80::1:6789", "invalid
Studio Server URL"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ target := Target{
+ Server: tt.server,
+ InstanceID: "test-instance",
+ Credential: Credential{AccessKey: "ak",
SecretKey: "sk"},
+ }
+ err := target.validate()
+ if tt.wantErr == "" {
+ if err != nil {
+ t.Fatalf("validate(%q) = %v, want nil",
tt.server, err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("validate(%q) = nil, want error
containing %q", tt.server, tt.wantErr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("validate(%q) = %v, want error
containing %q", tt.server, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+// The interface zone is part of the outgoing URL: it must survive URL joining
+// and request construction as the "%25eth0" escape, otherwise a scoped literal
+// cannot identify its link-local interface.
+func TestRequestPreservesScopedIPv6Zone(t *testing.T) {
+ var recorded *http.Request
+ client := NewClient(&http.Client{Transport: roundTripFunc(func(request
*http.Request) (*http.Response, error) {
+ recorded = request
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body:
io.NopCloser(strings.NewReader(`{"code":200,"message":"ok","data":null}`)),
+ Request: request,
+ }, nil
+ })})
+ target := Target{
+ Server: "http://[fe80::1%25eth0]:6789",
+ InstanceID: "instance-1",
+ Credential: Credential{AccessKey: "ak", SecretKey: "sk"},
+ Timeout: DefaultTimeout,
+ }
+
+ var payload any
+ if err := client.request(context.Background(), target, http.MethodGet,
"/api/brokers", nil, &payload); err != nil {
+ t.Fatalf("request to scoped link-local endpoint returned error:
%v", err)
+ }
+ if recorded == nil {
+ t.Fatal("request never reached the transport")
+ }
+ if got, want := recorded.URL.Hostname(), "fe80::1%eth0"; got != want {
+ t.Fatalf("hostname = %q, want %q", got, want)
+ }
+ if got, want := recorded.URL.String(),
"http://[fe80::1%25eth0]:6789/api/brokers"; got != want {
+ t.Fatalf("request URL = %q, want %q", got, want)
+ }
+}