Files
versitygw/embedgw/iam.go
T
niksis02 abb3b27149 feat: add standalone IAM support in WebGUI
Gates bucket listing behind an identity policy, lets browsers reach the standalone IAM API, and turns the WebUI into a dashboard for S3, IAM, or both.

**Bucket listing.** `ListBuckets` is now gated by the new `s3:ListAllMyBuckets` action, evaluated against `arn:aws:s3:::*`. The request names no bucket, so only identity policies apply — there is no resource policy to combine with, which is the same shape `CreateBucket` already had, so both now share one identity-only evaluation path. Root and admin bypass it, and backends with no identity-policy layer keep listing as before since their listing is already narrowed to the caller's own buckets. The action is IAM-only and is deliberately absent from the bucket-policy action list.

**Fixed bucket ownership.** The standalone IAM client has no per-user ownership to express — accounts are all plain users, cannot be enumerated, and access is decided by policy rather than ACL — so it now implements `auth.FixedBucketOwner` and every bucket is owned by root. Bucket creation stops resolving an owner, `ListBuckets` returns every bucket to every caller (what they may then do with one stays a per-request policy decision), and the admin `ChangeBucketOwner` reports method-not-supported. Other IAM backends are untouched.

**IAM service CORS.** `--cors-allow-origin` now applies to the `iam` command: it answers preflights and stamps the CORS headers, mirroring back the requested method and headers rather than enumerating the SigV4 header set. Without it no browser can reach the IAM API at all, so setting `--webui` without it falls back to `*` with a warning. The chart gets `iamServer.corsAllowOrigin`.

**WebUI.** New IAM pages for users, roles and OIDC providers, signing IAM/STS query-form requests directly from the browser. Navigation is capability-gated rather than role-gated: on sign-in the session probes the S3, admin and IAM endpoints independently and each page shows only what those credentials actually reach, so one build serves an IAM-only dashboard, an S3-only dashboard, and a combined one. The login page takes an optional IAM endpoint, seeded from the new `--webui-iam-gateways` (chart: `webui.iamGateways`) — never auto-detected, since the IAM service is a separate process. The WebUI can also be hosted by `versitygw iam` itself, for deployments with no S3 gateway behind it.

**The admin API is ignored once an IAM endpoint is in play.** The IAM service is then the user directory and bucket ownership is fixed, which leaves the admin API no job: the session is given no admin endpoint at all, its login field is hidden, `users.html` redirects to its IAM counterpart, and every admin-only surface stays off screen. Dashboard and Buckets remain available to any S3 session in such a deployment, running on the S3 and IAM APIs alone and surfacing each denial per action instead of redirecting.

Also fixes two WebUI bugs: embedded assets went out with a zero modification time and no `Cache-Control`, so browsers treated them as fresh for centuries and an upgraded gateway served new HTML against stale JS — they now revalidate against an ETag; and the login page's advanced-options section clipped its last field, since it animated to a height named in the stylesheet rather than the one it measures now.

**Usage**

IAM-only dashboard, served by the IAM service:

    versitygw iam --port :7076 --webui :8080 --cors-allow-origin http://localhost:8080/

IAM + S3, dashboard served by the IAM service — point it at the gateway with `--webui-gateways`, and let the gateway accept the dashboard's origin:

    versitygw iam --port :7076 --webui :8080 --webui-gateways http://localhost:7070/ --cors-allow-origin http://localhost:8080/
    versitygw --port :7070 --cors-allow-origin http://localhost:8080/ posix /data

IAM + S3, dashboard served by the S3 gateway — point it at the IAM service with `--webui-iam-gateways`, and let the IAM service accept the dashboard's origin:

    versitygw --port :7070 --webui :8080 --webui-iam-gateways http://localhost:7076/ posix /data
    versitygw iam --port :7076 --cors-allow-origin http://localhost:8080/
2026-08-27 20:28:51 +04:00

687 lines
23 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
}
// 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())
}
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
}