From 32e77ff980638e3c8f442873db67a6cc583fedac Mon Sep 17 00:00:00 2001 From: Deep <322813880+deepnemesis@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:03:47 +0530 Subject: [PATCH] Secure Weed Mini Admin Listeners by Default (#11613) * securing admin Signed-off-by: Subhadeep Maity * updated readme Signed-off-by: Subhadeep Maity <322813880+deepnemesis@users.noreply.github.com> * fixed pr comments Signed-off-by: Subhadeep Maity <322813880+deepnemesis@users.noreply.github.com> * docs: tidy weed mini admin bind notes Drop the new single-entry CHANGELOG.md since changes are documented via GitHub releases, and rewrap the README paragraph to match the surrounding one-line style without self-referential issue/PR links. * review comments Signed-off-by: Subhadeep Maity <322813880+deepnemesis@users.noreply.github.com> --------- Signed-off-by: Subhadeep Maity Signed-off-by: Subhadeep Maity <322813880+deepnemesis@users.noreply.github.com> Co-authored-by: Subhadeep Maity Co-authored-by: Chris Lu --- README.md | 2 + weed/command/mini.go | 123 +++++++++++++-- weed/command/mini_admin_auth_test.go | 217 ++++++++++++++++++++++++++- 3 files changed, 325 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ab2aa9258..5c794942b 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ AWS_ACCESS_KEY_ID=admin AWS_SECRET_ACCESS_KEY=secret \ The same process also runs the master, a volume server, the filer, WebDAV, the Iceberg REST catalog, and the Admin UI. Add `S3_TABLE_BUCKET=warehouse` to also create an Iceberg table bucket, or `warehouse:LANCE` for a Lance one. Drop the AWS keys to run without authentication for development. +Without Admin authentication or mTLS, `weed mini` binds the Admin UI/API and its worker gRPC control plane to loopback rather than `-ip.bind`. Set `WEED_ADMIN_PASSWORD` or configure `https.admin` mTLS to keep the network bind. Remote workers must opt in with `-admin.worker.ip=
` and should configure `grpc.admin` mTLS. `-admin.allowInsecureBind` restores the legacy unauthenticated network bind and should only be used on an isolated network. + > macOS: if the binary is quarantined, run `xattr -d com.apple.quarantine ./weed` first. `weed mini` is auto-tuned for one node and is fine for single-node production, such as an S3 gateway that issues presigned URLs. See [Quick Start with weed mini][WeedMini]. diff --git a/weed/command/mini.go b/weed/command/mini.go index 440aaa851..d607b909a 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -69,6 +69,7 @@ var ( miniEnableWebDAV *bool miniEnableS3 *bool miniEnableAdminUI *bool + miniAdminWorkerBindIP *string miniS3IamReadOnly *bool miniVolumeMaxDataVolumeCounts *string // MiniClusterCtx is the context for the mini cluster. If set, the mini cluster will stop when the context is cancelled. @@ -567,6 +568,8 @@ func initMiniAdminFlags() { miniAdminOptions.adminPassword = cmdMini.Flag.String("admin.password", "", "admin interface password (if empty, auth is disabled)") miniAdminOptions.readOnlyUser = cmdMini.Flag.String("admin.readOnlyUser", "", "read-only user username (optional, for view-only access)") miniAdminOptions.readOnlyPassword = cmdMini.Flag.String("admin.readOnlyPassword", "", "read-only user password (optional, for view-only access; requires admin.password to be set)") + miniAdminOptions.allowInsecureBind = cmdMini.Flag.Bool("admin.allowInsecureBind", false, "INSECURE: allow the unauthenticated admin interface to bind to -ip.bind instead of loopback") + miniAdminWorkerBindIP = cmdMini.Flag.String("admin.worker.ip", "127.0.0.1", "worker gRPC bind address; expose only with grpc.admin mTLS configured") miniAdminOptions.urlPrefix = cmdMini.Flag.String("admin.urlPrefix", "", "URL path prefix when running the admin UI behind a reverse proxy under a subdirectory (e.g. /seaweedfs)") } @@ -957,7 +960,11 @@ func ensureAllPortsAvailableOnIP(bindIp string) error { // first: an in-process rerun would otherwise inherit the closed listener // of the previous run and only find out inside Serve. miniAdminOptions.workerGrpcListener = nil - if listener, err := net.Listen("tcp", util.JoinHostPort(bindIp, *miniAdminOptions.grpcPort)); err != nil { + workerGrpcBindIP := miniAdminOptions.workerGrpcBindIp + if workerGrpcBindIP == "" { + workerGrpcBindIP = "127.0.0.1" + } + if listener, err := listenMiniAdminWorker(workerGrpcBindIP, *miniAdminOptions.grpcPort); err != nil { glog.Warningf("Could not reserve Admin gRPC port %d: %v", *miniAdminOptions.grpcPort, err) } else { miniAdminOptions.workerGrpcListener = listener @@ -984,6 +991,12 @@ func ensureAllPortsAvailableOnIP(bindIp string) error { return nil } +// listenMiniAdminWorker reserves the exact worker gRPC address that the Admin +// server will later use, preventing another connection from taking the port. +func listenMiniAdminWorker(bindIP string, port int) (net.Listener, error) { + return net.Listen("tcp", util.JoinHostPort(bindIP, port)) +} + // initializeGrpcPortsOnIP initializes all gRPC ports based on their HTTP ports on a specific IP // If a gRPC port is 0, it will be set to httpPort + GrpcPortOffset // This must be called after HTTP ports are finalized and before services start @@ -1239,6 +1252,10 @@ func runMini(cmd *Command, args []string) bool { // Determine bind IP bindIp := getBindIp() + miniAdminOptions.workerGrpcBindIp = *miniAdminWorkerBindIP + if miniAdminOptions.workerGrpcBindIp == "" { + miniAdminOptions.workerGrpcBindIp = "127.0.0.1" + } // Ensure all ports are available, find alternatives if needed if err := ensureAllPortsAvailableOnIP(bindIp); err != nil { @@ -1583,6 +1600,46 @@ func applyMiniAdminCredentialFallback(options *AdminOptions) { applyViperFallback(cmdMini, options.readOnlyPassword, "admin.readOnlyPassword", "admin.readonly.password") } +// miniAdminBindIP selects a loopback HTTP bind unless Admin authentication, +// mTLS, or an explicit insecure opt-out permits the requested address. +func miniAdminBindIP(requestedIP string, passwordConfigured, mtlsConfigured, allowInsecure bool) string { + if isLoopbackIp(requestedIP) || passwordConfigured || mtlsConfigured || allowInsecure { + return requestedIP + } + return "127.0.0.1" +} + +// miniAdminWorkerAddress encodes the finalized HTTP and gRPC ports in the +// server-address format understood by pb.ServerToGrpcAddress. +func miniAdminWorkerAddress(bindIP string, httpPort, grpcPort int) string { + return fmt.Sprintf("%s:%d.%d", miniAdminWorkerDialIP(bindIP), httpPort, grpcPort) +} + +// miniAdminWorkerDialIP maps unspecified listener addresses to same-family +// loopback destinations while preserving specific addresses for local dials. +func miniAdminWorkerDialIP(bindIP string) string { + ip := net.ParseIP(strings.TrimSuffix(strings.TrimPrefix(bindIP, "["), "]")) + if ip == nil || !ip.IsUnspecified() { + return bindIP + } + if ip.To4() != nil { + return "127.0.0.1" + } + return "::1" +} + +// miniAdminAdvertisedIP returns a reachable address for URLs in the welcome +// message while preserving a specifically selected Admin HTTP bind address. +func miniAdminAdvertisedIP() string { + if miniAdminOptions.ip == nil || *miniAdminOptions.ip == "" { + return *miniIp + } + if *miniAdminOptions.ip == "0.0.0.0" || *miniAdminOptions.ip == "::" { + return *miniIp + } + return *miniAdminOptions.ip +} + // startMiniAdminWithWorker starts the admin server with one worker func startMiniAdminWithWorker(allServicesReady chan struct{}) { defer close(allServicesReady) // Ensure channel is always closed on all paths @@ -1590,9 +1647,6 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // Admin shuts down when mini clients shutdown is triggered. ctx := miniClientsCtx() - // Determine bind IP for health checks - bindIp := getBindIp() - // Prepare master address with gRPC port masterAddr := string(pb.NewServerAddress(*miniIp, *miniMasterOptions.port, *miniMasterOptions.portGrpc)) @@ -1611,6 +1665,34 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // vars, matching the standalone `weed admin` command. applyMiniAdminCredentialFallback(&miniAdminOptions) + requestedBindIP := getBindIp() + hasMTLS := util.GetViper().GetString("https.admin.key") != "" && + util.GetViper().GetString("https.admin.ca") != "" + allowInsecure := miniAdminOptions.allowInsecureBind != nil && + *miniAdminOptions.allowInsecureBind + bindIP := miniAdminBindIP( + requestedBindIP, + *miniAdminOptions.adminPassword != "", + hasMTLS, + allowInsecure, + ) + miniAdminOptions.ip = &bindIP + if bindIP != requestedBindIP { + glog.Warningf( + "Admin authentication is disabled; binding the admin interface to %s instead of %s. "+ + "Set -admin.password, configure https.admin mTLS, or use "+ + "-admin.allowInsecureBind to retain the insecure network bind.", + bindIP, + requestedBindIP, + ) + } else if allowInsecure && !isLoopbackIp(bindIP) && + *miniAdminOptions.adminPassword == "" && !hasMTLS { + glog.Warningf( + "-admin.allowInsecureBind exposes the unauthenticated admin interface on %s.", + bindIP, + ) + } + // Security validation: prevent empty username when password is set if *miniAdminOptions.adminPassword != "" && *miniAdminOptions.adminUser == "" { glog.Fatalf("Error: -admin.user cannot be empty when -admin.password is set") @@ -1681,7 +1763,7 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { }() // Wait for admin server's HTTP port to be ready before launching worker - adminAddr := "http://" + util.JoinHostPort(bindIp, *miniAdminOptions.port) + adminAddr := "http://" + util.JoinHostPort(bindIP, *miniAdminOptions.port) if err := waitForAdminServerReady(ctx, adminAddr); err != nil { // If the parent context was cancelled (e.g. a previous in-process // mini run is being torn down), bail out gracefully instead of @@ -1705,7 +1787,10 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { startMiniPluginWorker(ctx, workerDir) // Wait for worker to be ready by polling its gRPC port - workerGrpcAddr := fmt.Sprintf("%s:%d", bindIp, *miniAdminOptions.grpcPort) + workerGrpcAddr := util.JoinHostPort( + miniAdminWorkerDialIP(miniAdminOptions.workerGrpcBindIp), + *miniAdminOptions.grpcPort, + ) waitForWorkerReady(workerGrpcAddr) if miniProgressBoard != nil { miniProgressBoard.ready("Admin") @@ -1783,7 +1868,11 @@ func waitForWorkerReady(workerGrpcAddr string) { func startMiniWorker(workerDir string) { glog.V(1).Infof("Initializing standard worker runtime") - adminAddr := fmt.Sprintf("%s:%d", *miniIp, *miniAdminOptions.port) + adminAddr := miniAdminWorkerAddress( + miniAdminOptions.workerGrpcBindIp, + *miniAdminOptions.port, + *miniAdminOptions.grpcPort, + ) capabilities := "vacuum,ec,balance" // Use common worker directory @@ -1858,11 +1947,11 @@ func startMiniWorker(workerDir string) { func startMiniPluginWorker(ctx context.Context, workerDir string) { glog.V(1).Infof("Starting plugin worker for admin server") - adminAddr := fmt.Sprintf("%s:%d", *miniIp, *miniAdminOptions.port) - resolvedAdminAddr := resolvePluginWorkerAdminServer(adminAddr) - if resolvedAdminAddr != adminAddr { - glog.V(1).Infof("Resolved mini plugin worker admin endpoint: %s -> %s", adminAddr, resolvedAdminAddr) - } + adminAddr := miniAdminWorkerAddress( + miniAdminOptions.workerGrpcBindIp, + *miniAdminOptions.port, + *miniAdminOptions.grpcPort, + ) // Use common worker directory @@ -1880,7 +1969,7 @@ func startMiniPluginWorker(ctx context.Context, workerDir string) { } pluginRuntime, err := pluginworker.NewWorker(pluginworker.WorkerOptions{ - AdminServer: resolvedAdminAddr, + AdminServer: adminAddr, WorkerID: workerID, WorkerVersion: version.Version(), WorkerAddress: *miniIp, @@ -1919,13 +2008,15 @@ const credentialsInstructionTemplate = ` Creates initial credentials for the 'mini' user and pre-creates the bucket. Option 2: Use the Admin UI - Open: http://%s:%d + Open: http://%s Add a new identity to create S3 credentials. ` // printWelcomeMessage prints the welcome message after all services are running func printWelcomeMessage() { var sb strings.Builder + adminIP := miniAdminAdvertisedIP() + adminHTTPAddress := util.JoinHostPort(adminIP, *miniAdminOptions.port) sb.WriteString("╔═══════════════════════════════════════════════════════════════════════════════╗\n") sb.WriteString("║ SeaweedFS Mini - All-in-One Mode ║\n") @@ -1947,7 +2038,7 @@ func printWelcomeMessage() { } } if *miniEnableAdminUI { - fmt.Fprintf(&sb, " Admin UI: http://%s:%d\n", *miniIp, *miniAdminOptions.port) + fmt.Fprintf(&sb, " Admin UI: http://%s\n", adminHTTPAddress) } fmt.Fprintf(&sb, "\n Data Directory: %s\n", *miniDataFolders) @@ -1970,7 +2061,7 @@ func printWelcomeMessage() { // run, configured via env vars, static config file, etc.) — no need // to show setup hints. case *miniEnableAdminUI: - fmt.Fprintf(&sb, credentialsInstructionTemplate, *miniIp, *miniAdminOptions.port) + fmt.Fprintf(&sb, credentialsInstructionTemplate, adminHTTPAddress) default: sb.WriteString("\n To create S3 credentials, use environment variables:\n\n") sb.WriteString(" export AWS_ACCESS_KEY_ID=your-access-key\n") diff --git a/weed/command/mini_admin_auth_test.go b/weed/command/mini_admin_auth_test.go index ce487cdd9..85fe8f2ad 100644 --- a/weed/command/mini_admin_auth_test.go +++ b/weed/command/mini_admin_auth_test.go @@ -1,12 +1,19 @@ package command -import "testing" +import ( + "net" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb" +) // weed mini must resolve admin credentials from security.toml [admin] / // WEED_ADMIN_* env vars the same way the standalone `weed admin` command does. // This exercises the production fallback so the flag-name -> viper-key mapping // stays correct, in particular the read-only keys where the mini flag // (admin.readOnlyUser) and viper key (admin.readonly.user) differ. +// TestApplyMiniAdminCredentialFallbackFromEnv verifies those environment +// fallbacks without starting the full mini cluster. func TestApplyMiniAdminCredentialFallbackFromEnv(t *testing.T) { adminUser, adminPassword, readOnlyUser, readOnlyPassword := "admin", "", "", "" options := &AdminOptions{ @@ -39,3 +46,211 @@ func TestApplyMiniAdminCredentialFallbackFromEnv(t *testing.T) { } } } + +// TestMiniAdminBindIP covers the authentication-dependent HTTP bind policy. +func TestMiniAdminBindIP(t *testing.T) { + tests := []struct { + name string + requestedIP string + passwordConfigured bool + mtlsConfigured bool + allowInsecure bool + want string + }{ + { + name: "unauthenticated wildcard binds to loopback", + requestedIP: "0.0.0.0", + want: "127.0.0.1", + }, + { + name: "unauthenticated IPv6 wildcard binds to loopback", + requestedIP: "::", + want: "127.0.0.1", + }, + { + name: "existing loopback bind is preserved", + requestedIP: "127.0.0.1", + want: "127.0.0.1", + }, + { + name: "password permits requested bind", + requestedIP: "0.0.0.0", + passwordConfigured: true, + want: "0.0.0.0", + }, + { + name: "mTLS permits requested bind", + requestedIP: "0.0.0.0", + mtlsConfigured: true, + want: "0.0.0.0", + }, + { + name: "explicit insecure opt-out permits requested bind", + requestedIP: "0.0.0.0", + allowInsecure: true, + want: "0.0.0.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := miniAdminBindIP( + tt.requestedIP, + tt.passwordConfigured, + tt.mtlsConfigured, + tt.allowInsecure, + ) + if got != tt.want { + t.Fatalf("miniAdminBindIP() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestMiniAdminWorkerBindDefaultsToLoopback protects the worker control-plane default. +func TestMiniAdminWorkerBindDefaultsToLoopback(t *testing.T) { + if miniAdminWorkerBindIP == nil { + t.Fatal("mini Admin worker bind flag is not initialized") + } + if got, want := *miniAdminWorkerBindIP, "127.0.0.1"; got != want { + t.Fatalf("default mini Admin worker bind IP = %q, want %q", got, want) + } +} + +// TestListenMiniAdminWorkerUsesRequestedAddress verifies the production +// listener is actually restricted to the configured loopback address. +func TestListenMiniAdminWorkerUsesRequestedAddress(t *testing.T) { + listener, err := listenMiniAdminWorker("127.0.0.1", 0) + if err != nil { + t.Fatalf("listen for mini Admin worker: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + + address, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("listener address type = %T, want *net.TCPAddr", listener.Addr()) + } + if !address.IP.IsLoopback() { + t.Fatalf("listener IP = %s, want loopback", address.IP) + } +} + +// TestMiniAdminWorkerAddressUsesFinalGrpcPort verifies that local workers do +// not have to discover or infer a custom Admin worker gRPC port. +func TestMiniAdminWorkerAddressUsesFinalGrpcPort(t *testing.T) { + tests := []struct { + name string + ip string + want string + wantGrpc string + }{ + { + name: "IPv4", + ip: "127.0.0.1", + want: "127.0.0.1:23646.34567", + wantGrpc: "127.0.0.1:34567", + }, + { + name: "IPv6", + ip: "::1", + want: "::1:23646.34567", + wantGrpc: "[::1]:34567", + }, + { + name: "IPv4 wildcard dials loopback", + ip: "0.0.0.0", + want: "127.0.0.1:23646.34567", + wantGrpc: "127.0.0.1:34567", + }, + { + name: "IPv6 wildcard dials loopback", + ip: "::", + want: "::1:23646.34567", + wantGrpc: "[::1]:34567", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + address := miniAdminWorkerAddress(tt.ip, 23646, 34567) + if address != tt.want { + t.Fatalf("miniAdminWorkerAddress() = %q, want %q", address, tt.want) + } + if got := pb.ServerToGrpcAddress(address); got != tt.wantGrpc { + t.Fatalf("pb.ServerToGrpcAddress() = %q, want %q", got, tt.wantGrpc) + } + }) + } +} + +// TestMiniAdminWorkerDialIP verifies wildcard listener addresses are converted +// into valid same-family destinations without rewriting specific addresses. +func TestMiniAdminWorkerDialIP(t *testing.T) { + tests := []struct { + bindIP string + want string + }{ + {bindIP: "0.0.0.0", want: "127.0.0.1"}, + {bindIP: "::", want: "::1"}, + {bindIP: "[::]", want: "::1"}, + {bindIP: "192.0.2.10", want: "192.0.2.10"}, + {bindIP: "2001:db8::10", want: "2001:db8::10"}, + {bindIP: "worker.internal", want: "worker.internal"}, + } + + for _, tt := range tests { + t.Run(tt.bindIP, func(t *testing.T) { + if got := miniAdminWorkerDialIP(tt.bindIP); got != tt.want { + t.Fatalf("miniAdminWorkerDialIP(%q) = %q, want %q", tt.bindIP, got, tt.want) + } + }) + } +} + +// TestMiniAdminAdvertisedIP verifies that the welcome message uses the +// selected loopback address but replaces wildcard binds with a reachable host. +func TestMiniAdminAdvertisedIP(t *testing.T) { + oldMiniIP := miniIp + oldAdminIP := miniAdminOptions.ip + t.Cleanup(func() { + miniIp = oldMiniIP + miniAdminOptions.ip = oldAdminIP + }) + + detectedIP := "192.0.2.10" + miniIp = &detectedIP + + tests := []struct { + name string + adminIP string + expected string + }{ + { + name: "selected loopback", + adminIP: "127.0.0.1", + expected: "127.0.0.1", + }, + { + name: "IPv4 wildcard", + adminIP: "0.0.0.0", + expected: detectedIP, + }, + { + name: "IPv6 wildcard", + adminIP: "::", + expected: detectedIP, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adminIP := tt.adminIP + miniAdminOptions.ip = &adminIP + if got := miniAdminAdvertisedIP(); got != tt.expected { + t.Fatalf("miniAdminAdvertisedIP() = %q, want %q", got, tt.expected) + } + }) + } +}