Secure Weed Mini Admin Listeners by Default (#11613)

* securing admin

Signed-off-by: Subhadeep Maity <smaity@slb.com>

* 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 <smaity@slb.com>
Signed-off-by: Subhadeep Maity <322813880+deepnemesis@users.noreply.github.com>
Co-authored-by: Subhadeep Maity <smaity@slb.com>
Co-authored-by: Chris Lu <chris.lu@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-06 13:33:47 +08:00
1 parent de75450655
commit 32e77ff980
3 files changed
+325 -17

No files matched your search

+2
View File
@@ -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=<address>` 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].
+107 -16
View File
@@ -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")
+216 -1
View File
@@ -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)
}
})
}
}