Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a805367
Initial plan
Copilot Feb 7, 2026
01db26c
Add custom API server endpoint configuration
Copilot Feb 7, 2026
7051d8f
Add tests for API server endpoint configuration
Copilot Feb 7, 2026
ef33855
Add documentation for custom API server endpoint
Copilot Feb 7, 2026
55cc354
Feature complete: Allow custom API server endpoint
Copilot Feb 7, 2026
bd2ff57
Address PR feedback: Add Helm tests and in-cluster docs
Copilot Feb 7, 2026
3ca5e5b
Add URL validation for custom API server endpoint
Copilot Feb 7, 2026
af843d9
Fix lint issues: Extract helper functions to reduce function length
Copilot Feb 7, 2026
b01a0ea
Address review comments: Add https validation, quote Helm values, imp…
Copilot Feb 7, 2026
0e82afc
Fix integration test formatting and use existing istrue constant
Copilot Feb 7, 2026
e38dcfb
Fix test logic: Fail if http URL doesn't return error
Copilot Feb 7, 2026
84a51f7
Add comprehensive URL validation and unit tests
Copilot Feb 7, 2026
940030a
Mention multiple cloud vendors and fix if-else chain
Copilot Feb 8, 2026
5042459
Co-authored-by: illume <9541+illume@users.noreply.github.com>
Copilot Feb 8, 2026
caad2ff
Move custom API endpoint docs to separate file
Copilot Feb 8, 2026
2007e3d
Fix critical nil pointer bug and improve validation
Copilot Feb 8, 2026
738c6e0
Fix lint errors: Split test and extract helper function
Copilot Feb 8, 2026
8d7a767
Add values.schema.json and redact secrets from error messages
Copilot Feb 8, 2026
28eef5f
Further redact error messages for maximum security
Copilot Feb 8, 2026
8573a16
Update kube-oidc-proxy repository URL
Copilot Feb 8, 2026
2b0d627
Add port validation to prevent runtime errors
Copilot Feb 8, 2026
99de9bc
Co-authored-by: illume <9541+illume@users.noreply.github.com>
Copilot Feb 8, 2026
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
23 changes: 12 additions & 11 deletions backend/cmd/headlamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,28 +427,29 @@
}

// In-cluster
if config.UseInCluster {

Check failure on line 430 in backend/cmd/headlamp.go

View workflow job for this annotation

GitHub Actions / build

`if config.UseInCluster` has complex nested blocks (complexity: 6) (nestif)
context, err := kubeconfig.GetInClusterContext(
config.InClusterContextName,
config.OidcIdpIssuerURL,
config.OidcClientID, config.OidcClientSecret,
strings.Join(config.OidcScopes, ","),
config.OidcSkipTLSVerify,
config.OidcCACert)
config.OidcCACert,
config.APIServerEndpoint)
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to get in-cluster context")
}

context.Source = kubeconfig.InCluster
} else {
context.Source = kubeconfig.InCluster

err = context.SetupProxy()
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
}
err = context.SetupProxy()
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to setup proxy for in-cluster context")
}

err = config.KubeConfigStore.AddContext(context)
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
err = config.KubeConfigStore.AddContext(context)
if err != nil {
logger.Log(logger.LevelError, nil, err, "Failed to add in-cluster context")
}
}
}

Expand Down
41 changes: 41 additions & 0 deletions backend/cmd/headlamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1731,3 +1731,44 @@ func TestHandleClusterServiceProxy(t *testing.T) {
assert.Equal(t, "OK", rr.Body.String())
}
}

// TestCustomAPIServerEndpoint tests the custom API server endpoint configuration
// with validation logic, simulating kube-oidc-proxy scenarios.
func TestCustomAPIServerEndpoint(t *testing.T) {
// Test https validation directly - should reject http URLs
// Since validation now happens before InClusterConfig, this will always test validation
_, err := kubeconfig.GetInClusterContext(
"test-cluster",
"", "", "", "",
false, "",
"http://insecure-proxy.example.com:443", // http scheme should be rejected
)

// Should always fail with validation error (validation happens before in-cluster check)
require.Error(t, err, "Expected error with http:// URL")
assert.Contains(t, err.Error(), "must be a full https:// URL",
"Error should be about https requirement")

// Test with valid https URL - will fail with in-cluster error if not actually in cluster
if os.Getenv("HEADLAMP_RUN_INTEGRATION_TESTS") != strconv.FormatBool(istrue) {
t.Skip("skipping full integration test (validation already tested above)")
}

ctx, err := kubeconfig.GetInClusterContext(
"test-cluster",
"", "", "", "",
false, "",
"https://kube-oidc-proxy.example.com:443", // Valid https endpoint
)
if err != nil {
if strings.Contains(err.Error(), "unable to load in-cluster configuration") {
t.Skip("not running in cluster environment, cannot test full functionality")
}

t.Fatalf("unexpected error with valid https endpoint: %v", err)
}

// If we get here, we're in a cluster and endpoint was accepted
assert.Equal(t, "https://kube-oidc-proxy.example.com:443", ctx.Cluster.Server,
"Custom endpoint should be used")
}
1 change: 1 addition & 0 deletions backend/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func buildHeadlampCFG(conf *config.Config, kubeConfigStore kubeconfig.ContextSto
return &headlampconfig.HeadlampCFG{
UseInCluster: conf.InCluster,
InClusterContextName: conf.InClusterContextName,
APIServerEndpoint: conf.APIServerEndpoint,
KubeConfigPath: conf.KubeConfigPath,
SkippedKubeContexts: conf.SkippedKubeContexts,
ListenAddr: conf.ListenAddr,
Expand Down
5 changes: 5 additions & 0 deletions backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Config struct {
Version bool `koanf:"version"`
InCluster bool `koanf:"in-cluster"`
InClusterContextName string `koanf:"in-cluster-context-name"`
APIServerEndpoint string `koanf:"api-server-endpoint"`
DevMode bool `koanf:"dev"`
InsecureSsl bool `koanf:"insecure-ssl"`
LogLevel string `koanf:"log-level"`
Expand Down Expand Up @@ -415,6 +416,10 @@ func addGeneralFlags(f *flag.FlagSet) {
f.Bool("version", false, "Print version information and exit")
f.Bool("in-cluster", false, "Set when running from a k8s cluster")
f.String("in-cluster-context-name", "main", "Name to use for the in-cluster Kubernetes context")
f.String(
"api-server-endpoint", "",
"Custom Kubernetes API server endpoint; only used with --in-cluster and must be a full https:// URL",
)
f.Bool("dev", false, "Allow connections from other origins")
f.Bool("cache-enabled", false, "K8s cache in backend")
f.Bool("no-browser", false, "Disable automatically opening the browser when using embedded frontend")
Expand Down
17 changes: 17 additions & 0 deletions backend/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ func TestParseBasic(t *testing.T) {
assert.Equal(t, config.DefaultMeUsernamePath, conf.MeUsernamePath)
},
},
{
name: "api_server_endpoint_flag",
args: []string{"go run ./cmd", "--api-server-endpoint=https://kube-proxy.example.com"},
verify: func(t *testing.T, conf *config.Config) {
assert.Equal(t, "https://kube-proxy.example.com", conf.APIServerEndpoint)
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -141,6 +148,16 @@ var ParseWithEnvTests = []struct {
assert.Equal(t, "warn", conf.LogLevel)
},
},
{
name: "api_server_endpoint_from_env",
args: []string{"go run ./cmd"},
env: map[string]string{
"HEADLAMP_CONFIG_API_SERVER_ENDPOINT": "https://kube-oidc-proxy.example.com:443",
},
verify: func(t *testing.T, conf *config.Config) {
assert.Equal(t, "https://kube-oidc-proxy.example.com:443", conf.APIServerEndpoint)
},
},
}

func TestParseWithEnv(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions backend/pkg/headlampconfig/headlampConfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type HeadlampConfig struct {
type HeadlampCFG struct {
UseInCluster bool
InClusterContextName string
APIServerEndpoint string
ListenAddr string
CacheEnabled bool
DevMode bool
Expand Down
3 changes: 3 additions & 0 deletions backend/pkg/kubeconfig/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import "net/http"
// BuildUserAgent is exported for testing.
var BuildUserAgent = buildUserAgent

// ValidateAPIServerEndpoint is exported for testing.
var ValidateAPIServerEndpoint = validateAPIServerEndpoint

// UserAgentRoundTripper is exported for testing.
type UserAgentRoundTripper struct {
Base roundTripperInterface
Expand Down
113 changes: 99 additions & 14 deletions backend/pkg/kubeconfig/kubeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,82 @@ func splitKubeConfigPath(path string) []string {
return strings.Split(path, delimiter)
}

// validateAPIServerEndpoint validates and returns a trimmed API server endpoint.
// Returns empty string if endpoint is empty, or an error if endpoint is invalid.
func validateAPIServerEndpoint(endpoint string) (string, error) {
trimmed := strings.TrimSpace(endpoint)
if trimmed == "" {
return "", nil
}

parsedURL, err := url.Parse(trimmed)
if err != nil || !parsedURL.IsAbs() || parsedURL.Host == "" || parsedURL.Hostname() == "" {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: must be an absolute URL with scheme and host",
trimmed,
)
}
Comment on lines +1013 to +1019
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateAPIServerEndpoint currently accepts endpoints with an invalid/non-numeric port (e.g. https://proxy.example.com:abc) because url.Parse does not validate the port component. That value would pass validation but later fail when the proxy/client tries to dial, producing a harder-to-diagnose runtime error. Consider validating parsedURL.Port() when present (parse to int and ensure 1-65535) and add a unit test for an invalid port case.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot make it so

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added port validation to check Port() when present and ensure it's a valid number between 1-65535. Added 4 test cases covering non-numeric ports (caught by url.Parse error), port 0, port 99999, and negative ports. Tests split into Format and Security functions to stay under 60-line limit. All 18 validation tests pass. (2b0d627)


if parsedURL.Scheme != "https" {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: must be a full https:// URL",
trimmed,
)
}

// Disallow embedded credentials, query strings, fragments, and non-root paths
if parsedURL.User != nil {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: must not include user info (credentials)",
trimmed,
)
}

if parsedURL.RawQuery != "" {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: must not include a query string",
trimmed,
)
}

if parsedURL.Fragment != "" {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: must not include a fragment",
trimmed,
)
}

if parsedURL.Path != "" && parsedURL.Path != "/" {
return "", fmt.Errorf(
"invalid custom API server endpoint %q: path must be empty or '/' (scheme+host[:port] only)",
trimmed,
)
}

return trimmed, nil
}

// buildOIDCConfig creates an OIDC configuration if the required parameters are provided.
func buildOIDCConfig(
clientID, issuerURL, scopes string,
clientSecret string,
skipTLSVerify bool,
caCert string,
) *OidcConfig {
if clientID == "" || issuerURL == "" || scopes == "" {
return nil
}

return &OidcConfig{
ClientID: clientID,
ClientSecret: clientSecret,
IdpIssuerURL: issuerURL,
Scopes: strings.Split(scopes, ","),
SkipTLSVerify: &skipTLSVerify,
CACert: &caCert,
}
}

// GetInClusterContext returns the in-cluster context.
func GetInClusterContext(
contextName string,
Expand All @@ -1009,14 +1085,28 @@ func GetInClusterContext(
oidcScopes string,
oidcSkipTLSVerify bool,
oidcCACert string,
customAPIServerEndpoint string,
) (*Context, error) {
// Validate custom endpoint first, before attempting to load in-cluster config
customEndpoint, err := validateAPIServerEndpoint(customAPIServerEndpoint)
if err != nil {
return nil, err
}

clusterConfig, err := rest.InClusterConfig()
if err != nil {
return nil, err
}

// Use custom API server endpoint if provided, otherwise use default from in-cluster config
apiServerHost := clusterConfig.Host

if customEndpoint != "" {
apiServerHost = customEndpoint
}

cluster := &api.Cluster{
Server: clusterConfig.Host,
Server: apiServerHost,
CertificateAuthority: clusterConfig.CAFile,
CertificateAuthorityData: clusterConfig.CAData,
}
Expand All @@ -1033,19 +1123,14 @@ func GetInClusterContext(

inClusterAuthInfo := &api.AuthInfo{}

var oidcConf *OidcConfig

if oidcClientID != "" && oidcIssuerURL != "" && oidcScopes != "" {
// client secret is optional for in-cluster OIDC configuration
oidcConf = &OidcConfig{
ClientID: oidcClientID,
ClientSecret: oidcClientSecret,
IdpIssuerURL: oidcIssuerURL,
Scopes: strings.Split(oidcScopes, ","),
SkipTLSVerify: &oidcSkipTLSVerify,
CACert: &oidcCACert,
}
}
oidcConf := buildOIDCConfig(
oidcClientID,
oidcIssuerURL,
oidcScopes,
oidcClientSecret,
oidcSkipTLSVerify,
oidcCACert,
)

return &Context{
Name: contextName,
Expand Down
Loading
Loading