mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +00:00
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.
708 lines
24 KiB
Go
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
|
|
}
|