Files
versitygw/embedgw/iam.go
T
niksis02 658c37907d feat: add OIDC endpoint relaxations for private/isolated networks
Closes #2364

`AssumeRoleWithWebIdentity` only ever trusted an `OIDC` provider reachable over verified `https`, at a publicly routable address, on the implicit `:443`. That posture is right for an internet-facing IdP but rejects every address an internal one can have, so a `SPIFFE/SPIRE` OIDC discovery provider in the same cluster — or as a sidecar in the same pod — could never be registered, let alone verified against, and no setting could express "this private address is the IdP".

Two opt-in flags on `versitygw iam`, both off by default:

`--oidc-allow-private-endpoints`
Permit a provider `Url` resolving to a loopback/private/link-local address, and an explicit port. Transport is unchanged: still `https`, still fully verified (a self-signed in-cluster cert is trusted the way AWS documents, through `ThumbprintList`).

`--oidc-allow-insecure-transport`
Additionally permit plaintext `http` provider URLs, discovery/JWKS endpoints and redirects, and drop TLS verification (`thumbprint` pinning included) for `https` ones.

Both apply uniformly to the thumbprint auto-fetch at `CreateOpenIDConnectProvider` time and to the discovery-document plus `JWKS` fetch at `AssumeRoleWithWebIdentity` time. Neither weakens anything past the endpoint: signature verification, issuer matching, audience and trust policy evaluation are untouched, and the DNS-resolve-once/dial-the-resolved-IP shape stays in place so a rebind still cannot redirect a connection.

An `http` provider keeps its scheme in its stored `Url`, `ARN` and `iss` matching, rather than being stripped like an `https` one — otherwise `"http://host"` and `"https://host"` would collapse onto a single ARN and storage key and each could satisfy the other's trust policy. It also stores an empty `ThumbprintList` rather than failing: a plaintext provider presents no certificate to thumbprint.

Helm: `iamServer.oidc.{allowPrivateEndpoints,allowInsecureTransport}`, alongside `disableThumbprintAutofetch` moved into the same block (the flat `iamServer.disableOidcThumbprintAutofetch` stays honored). Chart `0.4.1 -> 0.4.2`.

The WebUI's create-provider form no longer rejects `http` URLs and ports client-side; it cannot see the service's settings, so those two rules are left to the server, whose error surfaces as a toast like any other.
2026-09-08 16:30:37 +04:00

708 lines
24 KiB
Go

// Copyright 2026 Versity Software
// This file is 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 embedgw
import (
"context"
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"sync/atomic"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/iamapi"
"github.com/versity/versitygw/iamapi/private"
"github.com/versity/versitygw/iamapi/storage"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/webui"
)
const iamTitle = "VersityGW IAM API"
// IAMConfig holds all configuration options for running the VersityGW IAM API.
type IAMConfig struct {
// RootUserAccess is the access key ID used to authenticate IAM API
// requests. Required.
RootUserAccess string
// RootUserSecret is the secret access key used to authenticate IAM API
// requests. Required.
RootUserSecret string
// Ports is the list of IAM API listening addresses. Each entry accepts
// the same formats as Config.Ports: "host:port", ":port", file-backed
// UNIX socket paths, or Linux abstract namespace sockets prefixed with
// "@". Required.
Ports []string
// MaxConnections is the maximum number of concurrent TCP connections
// accepted by the IAM API server.
MaxConnections int
// MaxRequests is the maximum number of concurrent in-flight IAM API
// requests. Should not exceed MaxConnections.
MaxRequests int
// CertFile is the path to the TLS certificate file for the IAM API server.
// Both CertFile and KeyFile must be provided together to enable TLS.
CertFile string
// KeyFile is the path to the TLS private key file for the IAM API server.
KeyFile string
// LogLevel controls the debug logger: LevelSilent (default) prints
// nothing, LevelDebug prints full request/response details with
// secrets and tokens masked, and LevelUnsafe prints them unmasked.
// Never use LevelUnsafe in production.
LogLevel debuglogger.Level
// Quiet suppresses per-request summary logging and startup output.
Quiet bool
// KeepAlive enables HTTP keep-alive on IAM API connections.
KeepAlive bool
// HealthPath is the URL path for unauthenticated health-check requests
// (e.g. "/healthz"). The endpoint returns HTTP 200 for GET requests.
HealthPath string
// SocketPerm is the octal file-mode string for file-backed UNIX domain
// socket permissions. It has no effect on TCP/IP addresses or Linux
// abstract namespace sockets.
SocketPerm string
// PrivatePorts is the list of listening addresses for the standalone
// IAM service's private endpoints (derive-signing-key, evaluate-policy, resolve-identity)
// — see private.PrivateAPI. Each address must be a unix socket, or a TCP
// address with PrivateCertFile/PrivateKeyFile/PrivateClientCAFile all
// set (mTLS with mandatory client-certificate verification); anything
// else fails startup rather than serving these endpoints in the clear.
// Empty disables the private endpoints entirely.
PrivatePorts []string
// PrivateCertFile/PrivateKeyFile are the private listener's own TLS
// server certificate, distinct from CertFile/KeyFile (the public
// control-plane listener's certificate) since the two listeners have
// different security requirements.
PrivateCertFile string
PrivateKeyFile string
// PrivateClientCAFile verifies the S3 gateway's client certificate on
// the private listener. Required, together with PrivateCertFile/
// PrivateKeyFile, for any non-unix-socket PrivatePorts address.
PrivateClientCAFile string
// PrivateSocketPerm is the octal file-mode string for a file-backed
// unix-socket PrivatePorts address.
PrivateSocketPerm string
// IAMDir enables local file-backed IAM API storage. Set to the directory
// path where the IAM API user database is stored.
IAMDir string
// VaultEndpointURL enables Vault-backed IAM API storage.
VaultEndpointURL string
// VaultNamespace is the fallback Vault namespace used when the specific
// auth or secret-storage namespace is not set.
VaultNamespace string
// VaultSecretStoragePath is the KV v2 path prefix under which IAM users
// are stored (defaults to "iam").
VaultSecretStoragePath string
// VaultSecretStorageNamespace overrides VaultNamespace for KV operations.
VaultSecretStorageNamespace string
// VaultAuthMethod is the AppRole mount path (defaults to "approle").
VaultAuthMethod string
// VaultAuthNamespace overrides VaultNamespace for AppRole login.
VaultAuthNamespace string
// VaultMountPath is the KV v2 engine mount path (defaults to "kv-v2").
VaultMountPath string
// VaultRootToken authenticates with a root token instead of AppRole.
VaultRootToken string
// VaultRoleID is the AppRole role ID.
VaultRoleID string
// VaultRoleSecret is the AppRole secret ID.
VaultRoleSecret string
// VaultServerCert is the PEM-encoded Vault server TLS certificate for
// verification.
VaultServerCert string
// VaultClientCert is the PEM-encoded client TLS certificate presented to
// Vault.
VaultClientCert string
// VaultClientCertKey is the PEM-encoded private key for VaultClientCert.
VaultClientCertKey string
// CORSAllowOrigin is the Access-Control-Allow-Origin value the IAM API
// returns to browsers, and the switch that enables preflight handling.
// No browser can reach this API without it, so leaving it empty while
// WebuiPorts is set logs a warning and falls back to "*".
CORSAllowOrigin string
// The Webui* fields host the WebUI from the IAM service process, for
// deployments with no S3 gateway behind it. They mirror Config's Webui*
// fields, except that here the IAM gateway URLs are the auto-detected
// ones (from Ports) and the S3/admin URLs can only come from a flag.
//
// WebuiPorts is the list of listening addresses for the WebUI server.
// Empty disables the WebUI entirely.
WebuiPorts []string
// WebuiCertFile/WebuiKeyFile are the WebUI server's TLS certificate. When
// both are empty and WebuiNoTLS is not set, the WebUI inherits
// CertFile/KeyFile.
WebuiCertFile string
WebuiKeyFile string
// WebuiNoTLS forces the WebUI to plain HTTP even when TLS is configured
// for the IAM API.
WebuiNoTLS bool
// WebuiPathPrefix mounts the WebUI under a single-segment path prefix
// (e.g. "/ui").
WebuiPathPrefix string
// WebuiIAMGateways overrides the IAM service URLs auto-detected from
// Ports, for when the browser reaches the IAM API through a name this
// process cannot see, such as an ingress hostname.
WebuiIAMGateways []string
// WebuiGateways and WebuiAdminGateways are the S3 and admin gateway URLs
// offered on the login page. Neither is auto-detected here, so leaving
// both empty produces an IAM-only dashboard.
WebuiGateways []string
WebuiAdminGateways []string
// Region seeds the WebUI's default region selector. IAM's own signing
// region is fixed, so this only matters when WebuiGateways points the
// dashboard at an S3 gateway as well.
Region string
// SigHup is an optional channel that signals the IAM API to reload TLS
// certificates. When nil, this feature is disabled.
SigHup <-chan struct{}
// Version, Build, and BuildTime are displayed in the startup banner.
// All three are optional.
Version string
Build string
BuildTime string
// DisableOIDCThumbprintAutoFetch disables CreateOpenIDConnectProvider's
// TLS auto-fetch fallback for when ThumbprintList is omitted. When set,
// an omitted ThumbprintList is rejected instead of the IAM API making an
// outbound TLS connection to the caller-supplied URL — for restricted
// or air-gapped deployments.
DisableOIDCThumbprintAutoFetch bool
// OIDCAllowPrivateEndpoints permits OIDC provider URLs that resolve to
// loopback/private/link-local addresses and that carry an explicit port,
// both refused by default. Required to use an IdP that exists only on an
// internal network, such as a SPIFFE/SPIRE OIDC discovery provider on a
// cluster-internal Service. Transport stays https and fully verified.
OIDCAllowPrivateEndpoints bool
// OIDCAllowInsecureTransport permits plaintext http OIDC provider URLs
// and drops TLS certificate verification (ThumbprintList pinning
// included) for https ones, leaving the network path as the only thing
// authenticating the IdP. For an IdP reachable only over a path that is
// itself trusted, such as a discovery provider bound to loopback as a
// sidecar in this process's own pod.
OIDCAllowInsecureTransport bool
}
// privateAPIServer is the standalone IAM service's private endpoint set
// together with everything RunIAMAPI needs to serve and maintain it: the
// TLS options ServeMultiPort will enforce, and the cert storage backing
// them so a SIGHUP can swap in a rotated certificate.
type privateAPIServer struct {
api *private.PrivateAPI
tlsOpts netutil.TLSOptions
certStorage *netutil.CertStorage
}
// newPrivateAPI builds the standalone IAM service's private endpoint set
// and the TLS options ServeMultiPort will enforce (mTLS, or nothing at all
// for a unix-socket-only deployment — see netutil.RequireSecureTransport).
func newPrivateAPI(store storage.Storer, cfg *IAMConfig) (*privateAPIServer, error) {
allSet := cfg.PrivateCertFile != "" && cfg.PrivateKeyFile != "" && cfg.PrivateClientCAFile != ""
noneSet := cfg.PrivateCertFile == "" && cfg.PrivateKeyFile == "" && cfg.PrivateClientCAFile == ""
if !allSet && !noneSet {
return nil, fmt.Errorf("--private-cert, --private-cert-key, and --private-client-ca must all be set together, or all left empty for a unix-socket-only private listener")
}
var tlsOpts netutil.TLSOptions
var certStorage *netutil.CertStorage
if allSet {
certStorage = netutil.NewCertStorage()
if err := certStorage.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile); err != nil {
return nil, fmt.Errorf("private listener: load certs: %w", err)
}
pool, err := netutil.LoadCACertPool(cfg.PrivateClientCAFile)
if err != nil {
return nil, fmt.Errorf("private listener: %w", err)
}
tlsOpts = netutil.TLSOptions{
GetCertificate: certStorage.GetCertificate,
ClientCAs: pool,
RequireClientCert: true,
}
}
var privOpts []private.PrivateAPIOption
if cfg.PrivateSocketPerm != "" {
perm, err := strconv.ParseUint(cfg.PrivateSocketPerm, 8, 32)
if err != nil {
return nil, fmt.Errorf("invalid PrivateSocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.PrivateSocketPerm, err)
}
privOpts = append(privOpts, private.WithPrivateSocketPerm(os.FileMode(perm)))
}
if cfg.Quiet {
privOpts = append(privOpts, private.WithPrivateQuiet())
}
if cfg.Version != "" {
privOpts = append(privOpts, private.WithPrivateServerVersion(cfg.Version))
}
p, err := private.New(store, iamapi.RootCredentials{
Access: cfg.RootUserAccess,
Secret: cfg.RootUserSecret,
}, privOpts...)
if err != nil {
return nil, fmt.Errorf("init private IAM API: %w", err)
}
return &privateAPIServer{api: p, tlsOpts: tlsOpts, certStorage: certStorage}, nil
}
// iamWebUIGateways resolves the IAM service URLs the WebUI login page offers.
// This process is the IAM service, so its own listening addresses are the
// auto-detected answer unless the operator overrode them.
func iamWebUIGateways(cfg *IAMConfig) ([]string, error) {
if len(cfg.WebuiIAMGateways) > 0 {
return validateGatewayURLs(cfg.WebuiIAMGateways, "WebuiIAMGateways")
}
var gateways []string
for _, p := range cfg.Ports {
urls, err := buildServiceURLs(p, cfg.CertFile != "")
if err != nil {
return nil, fmt.Errorf("webui: build IAM gateway URLs: %w", err)
}
gateways = append(gateways, urls...)
}
sortGatewayURLs(gateways)
return gateways, nil
}
// newIAMWebUI builds the WebUI server hosted by the IAM service process. It
// returns nil when no WebuiPorts are configured.
func newIAMWebUI(cfg *IAMConfig) (*webui.Server, error) {
if len(cfg.WebuiPorts) == 0 {
return nil, nil
}
if err := validateWebUIPathPrefix("WebuiPathPrefix", cfg.WebuiPathPrefix); err != nil {
return nil, err
}
iamGateways, err := iamWebUIGateways(cfg)
if err != nil {
return nil, err
}
gateways, err := validateGatewayURLs(cfg.WebuiGateways, "WebuiGateways")
if err != nil {
return nil, err
}
adminGateways, err := validateGatewayURLs(cfg.WebuiAdminGateways, "WebuiAdminGateways")
if err != nil {
return nil, err
}
var webOpts []webui.Option
if !cfg.WebuiNoTLS {
webTLSCert, webTLSKey := cfg.WebuiCertFile, cfg.WebuiKeyFile
if webTLSCert == "" && webTLSKey == "" {
webTLSCert, webTLSKey = cfg.CertFile, cfg.KeyFile
}
if webTLSCert != "" || webTLSKey != "" {
if webTLSCert == "" {
return nil, fmt.Errorf("webui TLS key specified without cert file")
}
if webTLSKey == "" {
return nil, fmt.Errorf("webui TLS cert specified without key file")
}
cs := netutil.NewCertStorage()
if err := cs.SetCertificate(webTLSCert, webTLSKey); err != nil {
return nil, fmt.Errorf("tls: load certs: %v", err)
}
webOpts = append(webOpts, webui.WithTLS(cs))
}
}
if cfg.Quiet {
webOpts = append(webOpts, webui.WithQuiet())
}
if cfg.WebuiPathPrefix != "" {
webOpts = append(webOpts, webui.WithPathPrefix(cfg.WebuiPathPrefix))
}
if cfg.SocketPerm != "" {
perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32)
if err != nil {
return nil, fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err)
}
webOpts = append(webOpts, webui.WithSocketPerm(os.FileMode(perm)))
}
return webui.NewServer(&webui.ServerConfig{
Gateways: gateways,
AdminGateways: adminGateways,
IAMGateways: iamGateways,
Region: cfg.Region,
}, webOpts...)
}
var iamAPIRunning atomic.Bool
// RunIAMAPI starts the VersityGW IAM API with the supplied configuration. It
// blocks until ctx is cancelled, or an error occurs. The server is gracefully
// shut down before the function returns.
//
// Only one IAM API instance may run per process at a time. Calling RunIAMAPI
// concurrently or a second time before the first call returns will return an
// error.
func RunIAMAPI(ctx context.Context, cfg *IAMConfig) error {
if cfg == nil {
return fmt.Errorf("iam config is required")
}
if !iamAPIRunning.CompareAndSwap(false, true) {
return fmt.Errorf("embedgw: RunIAMAPI is already running; only one instance per process is supported")
}
defer iamAPIRunning.Store(false)
if cfg.MaxConnections < 1 {
return fmt.Errorf("max-connections must be positive")
}
if cfg.MaxRequests < 1 {
return fmt.Errorf("max-requests must be positive")
}
if cfg.MaxRequests > cfg.MaxConnections {
log.Printf("WARNING: max-requests (%d) exceeds max-connections (%d) which could allow for IAM API to panic before throttling requests",
cfg.MaxRequests, cfg.MaxConnections)
}
if len(cfg.Ports) == 0 {
return fmt.Errorf("no ports specified")
}
if cfg.RootUserAccess == "" {
return fmt.Errorf("root access key is required for IAM API authentication")
}
if cfg.RootUserSecret == "" {
return fmt.Errorf("root secret key is required for IAM API authentication")
}
store, err := storage.New(storage.Config{
Dir: cfg.IAMDir,
Vault: storage.VaultConfig{
EndpointURL: cfg.VaultEndpointURL,
Namespace: cfg.VaultNamespace,
SecretStoragePath: cfg.VaultSecretStoragePath,
SecretStorageNamespace: cfg.VaultSecretStorageNamespace,
AuthMethod: cfg.VaultAuthMethod,
AuthNamespace: cfg.VaultAuthNamespace,
MountPath: cfg.VaultMountPath,
RootToken: cfg.VaultRootToken,
RoleID: cfg.VaultRoleID,
RoleSecret: cfg.VaultRoleSecret,
ServerCert: cfg.VaultServerCert,
ClientCert: cfg.VaultClientCert,
ClientCertKey: cfg.VaultClientCertKey,
},
})
if err != nil {
return err
}
opts := []iamapi.Option{
iamapi.WithConcurrencyLimiter(cfg.MaxConnections, cfg.MaxRequests),
}
if cfg.HealthPath != "" {
opts = append(opts, iamapi.WithHealth(cfg.HealthPath))
}
if cfg.KeepAlive {
opts = append(opts, iamapi.WithKeepAlive())
}
if cfg.Quiet {
opts = append(opts, iamapi.WithQuiet())
}
if cfg.DisableOIDCThumbprintAutoFetch {
opts = append(opts, iamapi.WithOIDCThumbprintAutoFetchDisabled())
}
if cfg.OIDCAllowPrivateEndpoints {
opts = append(opts, iamapi.WithOIDCAllowPrivateEndpoints())
}
if cfg.OIDCAllowInsecureTransport {
opts = append(opts, iamapi.WithOIDCAllowInsecureTransport())
}
corsAllowOrigin := strings.TrimSpace(cfg.CORSAllowOrigin)
if len(cfg.WebuiPorts) > 0 && corsAllowOrigin == "" {
// Every WebUI call to this API is cross-origin, so without an allowed
// origin the dashboard this process serves cannot talk to it at all.
corsAllowOrigin = "*"
fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; consider setting it to the WebUI's own origin\n")
}
if corsAllowOrigin != "" {
opts = append(opts, iamapi.WithCORSAllowOrigin(corsAllowOrigin))
}
debuglogger.SetLevel(cfg.LogLevel)
if cfg.SocketPerm != "" {
perm, err := strconv.ParseUint(cfg.SocketPerm, 8, 32)
if err != nil {
return fmt.Errorf("invalid SocketPerm value %q: must be an octal integer (e.g. '0660'): %w", cfg.SocketPerm, err)
}
opts = append(opts, iamapi.WithSocketPerm(os.FileMode(perm)))
}
if cfg.CertFile != "" || cfg.KeyFile != "" {
if cfg.CertFile == "" {
return fmt.Errorf("TLS key specified without cert file")
}
if cfg.KeyFile == "" {
return fmt.Errorf("TLS cert specified without key file")
}
cs := iamapi.NewCertStorage()
if err := cs.SetCertificate(cfg.CertFile, cfg.KeyFile); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, iamapi.WithTLS(cs))
}
server, err := iamapi.New(store, iamapi.RootCredentials{
Access: cfg.RootUserAccess,
Secret: cfg.RootUserSecret,
}, opts...)
if err != nil {
return fmt.Errorf("init IAM API server: %w", err)
}
var privateAPI *privateAPIServer
if len(cfg.PrivatePorts) > 0 {
privateAPI, err = newPrivateAPI(store, cfg)
if err != nil {
return err
}
}
webSrv, err := newIAMWebUI(cfg)
if err != nil {
return fmt.Errorf("init webui: %w", err)
}
if !cfg.Quiet {
cfg.printBanner()
}
errCh := make(chan error, 3)
go func() {
errCh <- server.ServeMultiPort(cfg.Ports)
}()
if privateAPI != nil {
go func() {
errCh <- privateAPI.api.ServeMultiPort(cfg.PrivatePorts, privateAPI.tlsOpts)
}()
}
if webSrv != nil {
go func() {
errCh <- webSrv.ServeMultiPort(cfg.WebuiPorts)
}()
}
var sigHup <-chan struct{}
if cfg.SigHup != nil {
sigHup = cfg.SigHup
} else {
sigHup = make(chan struct{})
}
Loop:
for {
select {
case <-ctx.Done():
break Loop
case err = <-errCh:
break Loop
case <-sigHup:
if cfg.CertFile != "" && cfg.KeyFile != "" && server.CertStorage != nil {
reloadErr := server.CertStorage.SetCertificate(cfg.CertFile, cfg.KeyFile)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("iam api cert reload failed: %w", reloadErr))
} else {
fmt.Printf("iam api cert reloaded (cert: %s, key: %s)\n", cfg.CertFile, cfg.KeyFile)
}
}
// the private listener has its own certificate, so it needs
// its own reload: without this, new gateway-to-IAM TLS
// connections would keep getting the pre-rotation cert until
// the IAM service restarts.
if privateAPI != nil && privateAPI.certStorage != nil {
reloadErr := privateAPI.certStorage.SetCertificate(cfg.PrivateCertFile, cfg.PrivateKeyFile)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("private iam api cert reload failed: %w", reloadErr))
} else {
fmt.Printf("private iam api cert reloaded (cert: %s, key: %s)\n", cfg.PrivateCertFile, cfg.PrivateKeyFile)
}
}
}
}
saveErr := err
if err := server.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "shutdown IAM API server: %v\n", err)
}
if privateAPI != nil {
if err := privateAPI.api.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "shutdown private IAM API server: %v\n", err)
}
}
if webSrv != nil {
if err := webSrv.Shutdown(); err != nil {
fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err)
}
}
return saveErr
}
func (cfg IAMConfig) printBanner() {
if len(cfg.Ports) == 0 {
fmt.Fprintf(os.Stderr, "No ports specified\n")
return
}
allInterfaces, allPorts := resolveIAMBannerInterfaces(cfg.Ports)
if len(allInterfaces) == 0 {
fmt.Fprintf(os.Stderr, "Failed to resolve any listening addresses\n")
return
}
versionStr := fmt.Sprintf("Version %v, Build %v", cfg.Version, cfg.Build)
if cfg.BuildTime != "" {
versionStr += fmt.Sprintf(", BuildTime %v", cfg.BuildTime)
}
lines := []string{
centerText(iamTitle),
centerText(versionStr),
centerText(formatIAMBannerBoundHost(cfg.Ports, allPorts)),
centerText(""),
leftText("IAM API service listening on:"),
}
for _, u := range buildIAMBannerURLs(allInterfaces, cfg.CertFile != "" || cfg.KeyFile != "") {
lines = append(lines, leftText(" "+u))
}
if len(cfg.PrivatePorts) > 0 {
privateInterfaces, _ := resolveIAMBannerInterfaces(cfg.PrivatePorts)
if len(privateInterfaces) > 0 {
lines = append(lines, centerText(""), leftText("IAM private service listening on:"))
for _, u := range buildIAMBannerURLs(privateInterfaces, cfg.PrivateCertFile != "" || cfg.PrivateKeyFile != "") {
lines = append(lines, leftText(" "+u))
}
}
}
if len(cfg.WebuiPorts) > 0 {
webuiInterfaces, _ := resolveIAMBannerInterfaces(cfg.WebuiPorts)
if len(webuiInterfaces) > 0 {
webuiTLS := !cfg.WebuiNoTLS &&
(cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
lines = append(lines, centerText(""), leftText("Web dashboard listening on:"))
for _, u := range buildIAMBannerURLs(webuiInterfaces, webuiTLS) {
lines = append(lines, leftText(" "+u+cfg.WebuiPathPrefix))
}
}
}
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
for _, line := range lines {
fmt.Printf("│%-*s│\n", columnWidth-2, line)
}
fmt.Println("└" + strings.Repeat("─", columnWidth-2) + "┘")
}
func resolveIAMBannerInterfaces(ports []string) ([]string, []string) {
var allInterfaces []string
var allPorts []string
interfaceMap := make(map[string]bool)
for _, portSpec := range ports {
if netutil.IsUnixSocketPath(portSpec) {
allPorts = append(allPorts, portSpec)
if !interfaceMap[portSpec] {
interfaceMap[portSpec] = true
allInterfaces = append(allInterfaces, portSpec)
}
continue
}
interfaces, err := getMatchingIPs(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses for %s: %v\n", portSpec, err)
continue
}
_, prt, err := net.SplitHostPort(portSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse port %s: %v\n", portSpec, err)
continue
}
allPorts = append(allPorts, prt)
for _, ip := range interfaces {
key := net.JoinHostPort(ip, prt)
if !interfaceMap[key] {
interfaceMap[key] = true
allInterfaces = append(allInterfaces, key)
}
}
}
return allInterfaces, allPorts
}
func formatIAMBannerBoundHost(ports, allPorts []string) string {
if len(ports) == 1 {
if netutil.IsUnixSocketPath(ports[0]) {
return fmt.Sprintf("(unix socket: %s)", ports[0])
}
hst, prt, _ := net.SplitHostPort(ports[0])
if hst == "" {
hst = "0.0.0.0"
}
return fmt.Sprintf("(bound on host %s and port %s)", hst, prt)
}
return fmt.Sprintf("(bound on ports: %s)", strings.Join(allPorts, ", "))
}
func buildIAMBannerURLs(interfaces []string, tls bool) []string {
var urls []string
scheme := "http"
if tls {
scheme = "https"
}
for _, addrPort := range interfaces {
if netutil.IsUnixSocketPath(addrPort) {
urls = append(urls, "unix:"+addrPort)
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
urls = append(urls, fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(ip, prt)))
}
return urls
}