Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions cmd/apiserver/bind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"errors"
"fmt"
"net"
)

// errEmptyPlainBindAddress refuses an empty --rest-bind-address. Unlike the
// TLS flag, where empty turns the listener off, an empty address here would
// reach net.Listen and bind every interface on a random port.
var errEmptyPlainBindAddress = errors.New(
"--rest-bind-address is empty, which would bind every interface on a random port; " +
"give a loopback address such as " + defaultPlainBindAddress)

// defaultPlainBindAddress is where the plain-HTTP listener binds unless a
// flag says otherwise. That listener serves the whole API without a client
// certificate, so it stays on loopback: `kubectl port-forward` reaches it
// through the pod's own network namespace, and no other pod can.
const defaultPlainBindAddress = "127.0.0.1:3370"

// checkPlainBindAddress refuses an empty or unparsable address for the
// plain listener, and reports whether a valid one is off loopback, where it
// serves the whole API without a certificate to every pod that can reach
// it. A deployment applied from an older manifest still passes `:3370`.
func checkPlainBindAddress(addr string) (bool, error) {
if addr == "" {
return false, errEmptyPlainBindAddress
}

host, _, err := net.SplitHostPort(addr)
if err != nil {
return false, fmt.Errorf("--rest-bind-address %q: %w", addr, err)
}

if host == "localhost" {
return false, nil
}

ip := net.ParseIP(host)

return ip == nil || !ip.IsLoopback(), nil
}
48 changes: 48 additions & 0 deletions cmd/apiserver/bind_flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"go/ast"
"go/parser"
"go/token"
"testing"
)

// The constant being loopback proves nothing if the flag is registered with
// a literal instead: `":3370"` back at the flag call binds every interface
// again. So the registration itself has to name defaultPlainBindAddress.
func TestPlainListenerFlagUsesTheLoopbackDefault(t *testing.T) {
t.Parallel()

file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}

found := false

ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 3 {
return true
}

name, ok := call.Args[1].(*ast.BasicLit)
if !ok || name.Value != `"rest-bind-address"` {
return true
}

found = true

if ident, ok := call.Args[2].(*ast.Ident); !ok || ident.Name != "defaultPlainBindAddress" {
t.Errorf("rest-bind-address is registered with a default other than defaultPlainBindAddress")
}

return true
})

if !found {
t.Fatal("no rest-bind-address flag registration found in main.go")
}
}
90 changes: 90 additions & 0 deletions cmd/apiserver/bind_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"net"
"os"
"regexp"
"testing"
)

// The plain listener has no authentication. A default that binds every
// interface hands the full API to any pod that can reach the pod IP.
func TestPlainListenerDefaultsToLoopback(t *testing.T) {
t.Parallel()

assertLoopback(t, "default", defaultPlainBindAddress)
}

// The manifest passes the flag explicitly, so it overrides the default and
// needs the same guard.
func TestStandManifestKeepsPlainListenerOnLoopback(t *testing.T) {
t.Parallel()

raw, err := os.ReadFile("../../stand/blockstor-apiserver-deploy.yaml")
if err != nil {
t.Fatalf("read manifest: %v", err)
}

matches := regexp.MustCompile(`--rest-bind-address=(\S+)`).FindAllSubmatch(raw, -1)
if len(matches) == 0 {
t.Fatal("the manifest no longer sets --rest-bind-address; check what binds the plain listener")
}

for _, m := range matches {
assertLoopback(t, "manifest", string(m[1]))
}
}

func assertLoopback(t *testing.T, where, addr string) {
t.Helper()

host, _, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("%s: %q is not host:port: %v", where, addr, err)
}

ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
t.Fatalf("%s: plain listener binds %q, want a loopback address", where, addr)
}
}

// An empty address is refused, since net.Listen would bind every interface
// on a random port, and an address off loopback is flagged as exposed.
func TestCheckPlainBindAddress(t *testing.T) {
t.Parallel()

for addr, want := range map[string]struct{ exposed, fails bool }{
"": {fails: true},
"3370": {fails: true},
"127.0.0.1:3370": {},
"[::1]:3370": {},
"localhost:3370": {},
":3370": {exposed: true},
"0.0.0.0:3370": {exposed: true},
"10.0.0.5:3370": {exposed: true},
} {
exposed, err := checkPlainBindAddress(addr)
if (err != nil) != want.fails || exposed != want.exposed {
t.Errorf("checkPlainBindAddress(%q) = %v, %v; want exposed=%v fails=%v", addr, exposed, err, want.exposed, want.fails)
}
}
}
16 changes: 14 additions & 2 deletions cmd/apiserver/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ type apiserverFlags struct {
func parseFlags() *apiserverFlags {
flags := &apiserverFlags{}

flag.StringVar(&flags.restAddr, "rest-bind-address", ":3370",
"Plain-HTTP debug bind address. NOT exposed by the in-cluster Service — reachable only via `kubectl port-forward` for local debugging.")
flag.StringVar(&flags.restAddr, "rest-bind-address", defaultPlainBindAddress,
"Plain-HTTP debug bind address. It serves the full API without authentication, so the default is loopback: "+
"`kubectl port-forward` still reaches it, other pods do not.")
flag.StringVar(&flags.tlsAddr, "tls-bind-address", ":3371",
"Mutual-TLS bind address (upstream LINSTOR HTTPS port). The in-cluster Service exposes ONLY this port. Requires --tls-cert-file/--tls-key-file/--tls-client-ca-file; empty disables the TLS listener.")
flag.StringVar(&flags.tlsCertFile, "tls-cert-file", "",
Expand Down Expand Up @@ -192,6 +193,17 @@ func registerProbesAndREST(mgr manager.Manager, st store.Store, flags *apiserver
return errors.Wrap(err, "add readyz check")
}

exposed, err := checkPlainBindAddress(flags.restAddr)
if err != nil {
return err
}

if exposed {
ctrl.Log.WithName("setup").Info("WARNING: the plain-HTTP listener serves the whole API without a "+
"client certificate and is bound off loopback; any pod that can reach this address can use it",
"address", flags.restAddr)
}

err = mgr.Add(&rest.Server{
Addr: flags.restAddr,
TLS: tlsOptions(flags),
Expand Down
61 changes: 61 additions & 0 deletions cmd/controller/bind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: Apache-2.0

/*
Copyright 2026 Cozystack contributors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"errors"
"fmt"
"net"
)

// errEmptyPlainBindAddress refuses an empty --rest-bind-address. Unlike the
// TLS flag, where empty turns the listener off, an empty address here would
// reach net.Listen and bind every interface on a random port.
var errEmptyPlainBindAddress = errors.New(
"--rest-bind-address is empty, which would bind every interface on a random port; " +
"give a loopback address such as " + defaultPlainBindAddress)

// defaultPlainBindAddress is where the plain-HTTP listener binds unless a
// flag says otherwise. That listener serves the whole API without a client
// certificate, so it stays on loopback: `kubectl port-forward` reaches it
// through the pod's own network namespace, and no other pod can.
const defaultPlainBindAddress = "127.0.0.1:3370"

// checkPlainBindAddress refuses an empty or unparsable address for the
// plain listener, and reports whether a valid one is off loopback, where it
// serves the whole API without a certificate to every pod that can reach
// it. A deployment applied from an older manifest still passes `:3370`.
func checkPlainBindAddress(addr string) (bool, error) {
if addr == "" {
return false, errEmptyPlainBindAddress
}

host, _, err := net.SplitHostPort(addr)
if err != nil {
return false, fmt.Errorf("--rest-bind-address %q: %w", addr, err)
}

if host == "localhost" {
return false, nil
}

ip := net.ParseIP(host)

return ip == nil || !ip.IsLoopback(), nil
}
48 changes: 48 additions & 0 deletions cmd/controller/bind_flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"go/ast"
"go/parser"
"go/token"
"testing"
)

// The constant being loopback proves nothing if the flag is registered with
// a literal instead: `":3370"` back at the flag call binds every interface
// again. So the registration itself has to name defaultPlainBindAddress.
func TestPlainListenerFlagUsesTheLoopbackDefault(t *testing.T) {
t.Parallel()

file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}

found := false

ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 3 {
return true
}

name, ok := call.Args[1].(*ast.BasicLit)
if !ok || name.Value != `"rest-bind-address"` {
return true
}

found = true

if ident, ok := call.Args[2].(*ast.Ident); !ok || ident.Name != "defaultPlainBindAddress" {
t.Errorf("rest-bind-address is registered with a default other than defaultPlainBindAddress")
}

return true
})

if !found {
t.Fatal("no rest-bind-address flag registration found in main.go")
}
}
Loading
Loading