diff --git a/go.mod b/go.mod index 808201ea..2ccba870 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BobuSumisu/aho-corasick v1.0.3 github.com/Masterminds/sprig/v3 v3.3.0 github.com/awnumar/memguard v0.23.0 + github.com/aws/aws-sdk-go-v2 v1.27.2 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/creack/pty v1.1.21 @@ -63,7 +64,6 @@ require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect github.com/awnumar/memcall v0.4.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect diff --git a/packages/api/api.go b/packages/api/api.go index be39cd47..fdaea718 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -50,6 +50,7 @@ const ( operationCallRegisterGateway = "CallRegisterGateway" operationCallConnectGateway = "CallConnectGateway" operationCallEnrollGateway = "CallEnrollGateway" + operationCallAwsAuthLoginGateway = "CallAwsAuthLoginGateway" operationCallPAMAccess = "CallPAMAccess" operationCallPAMAccessApprovalRequest = "CallPAMAccessApprovalRequest" operationCallPAMSessionCredentials = "CallPAMSessionCredentials" @@ -957,6 +958,26 @@ func CallEnrollGateway(httpClient *resty.Client, request EnrollGatewayRequest) ( return resBody, nil } +func CallAwsAuthLoginGateway(httpClient *resty.Client, request AwsAuthLoginGatewayRequest) (AwsAuthLoginGatewayResponse, error) { + var resBody AwsAuthLoginGatewayResponse + response, err := httpClient. + R(). + SetResult(&resBody). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v3/gateways/login", config.INFISICAL_URL)) + + if err != nil { + return AwsAuthLoginGatewayResponse{}, NewGenericRequestError(operationCallAwsAuthLoginGateway, err) + } + + if response.IsError() { + return AwsAuthLoginGatewayResponse{}, NewAPIErrorWithResponse(operationCallAwsAuthLoginGateway, response, nil) + } + + return resBody, nil +} + func CallPAMAccess(httpClient *resty.Client, request PAMAccessRequest) (PAMAccessResponse, error) { var pamAccessResponse PAMAccessResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index 4000da29..90a0e77a 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -803,6 +803,19 @@ type EnrollGatewayResponse struct { GatewayID string `json:"gatewayId"` } +type AwsAuthLoginGatewayRequest struct { + Method string `json:"method"` + GatewayID string `json:"gatewayId"` + HTTPRequestMethod string `json:"iamHttpRequestMethod"` + IamRequestBody string `json:"iamRequestBody"` + IamRequestHeaders string `json:"iamRequestHeaders"` +} + +type AwsAuthLoginGatewayResponse struct { + AccessToken string `json:"accessToken"` + TokenType string `json:"tokenType"` +} + type RegisterGatewayResponse struct { GatewayID string `json:"gatewayId"` RelayHost string `json:"relayHost"` diff --git a/packages/cmd/gateway.go b/packages/cmd/gateway.go index 080e1a69..62726ec0 100644 --- a/packages/cmd/gateway.go +++ b/packages/cmd/gateway.go @@ -211,6 +211,10 @@ var gatewayStartCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { enrollMethod, _ := cmd.Flags().GetString("enroll-method") + // Fall back to env var for systemd-managed runs where flags aren't set. + if enrollMethod == "" { + enrollMethod = os.Getenv("INFISICAL_GATEWAY_ENROLL_METHOD") + } var alreadyEnrolled bool var enrolledAccessToken string // set during fresh enrollment, used directly to avoid env var interference @@ -226,6 +230,62 @@ var gatewayStartCmd = &cobra.Command{ util.HandleError(errors.New("gateway name is required (provide as positional argument)")) } + // --- AWS Auth path --- + if enrollMethod == gatewayv2.EnrollMethodAws { + gatewayID, _ := cmd.Flags().GetString("gateway-id") + if gatewayID == "" { + gatewayID = os.Getenv(gatewayv2.INFISICAL_GATEWAY_ID_KEY) + } + if gatewayID == "" { + stored, _ := gatewayv2.LoadStoredGatewayID(gatewayName) + gatewayID = stored + } + if gatewayID == "" { + util.HandleError(errors.New("--gateway-id is required when --enroll-method=aws")) + } + + domain, _ := cmd.Flags().GetString("domain") + if domain != "" { + config.INFISICAL_URL = util.AppendAPIEndpoint(domain) + } else if storedDomain, _ := gatewayv2.LoadStoredDomain(gatewayName); storedDomain != "" { + config.INFISICAL_URL = util.AppendAPIEndpoint(storedDomain) + } + + httpClient, err := util.GetRestyClientWithCustomHeaders() + if err != nil { + util.HandleError(err, "unable to create HTTP client") + } + + log.Info().Msg("Authenticating gateway via AWS Auth (STS GetCallerIdentity)...") + accessTokenStr, err := gatewayv2.LoginGatewayWithAws(cmd.Context(), httpClient, gatewayID) + if err != nil { + util.HandleError(err, "AWS Auth login failed") + } + + enrolledAccessToken = accessTokenStr + alreadyEnrolled = true // skip the stored-token branch below; we have a fresh one in hand + + // Don't persist the JWT — AWS-auth re-mints a fresh one on every start, so any + // on-disk copy would be stale and is never read back. enrolledAccessToken (in + // memory) is what downstream code uses. + if err := gatewayv2.SaveGatewayID(gatewayName, gatewayID); err != nil { + util.HandleError(err, "failed to save gateway id to config") + } + + effectiveDomain := domain + if effectiveDomain == "" { + effectiveDomain = config.INFISICAL_URL + } + if effectiveDomain != "" { + if err := gatewayv2.SaveDomain(gatewayName, effectiveDomain); err != nil { + util.HandleError(err, "failed to save domain to config") + } + } + + log.Info().Msgf("Gateway authenticated via AWS Auth. State saved to %s", gatewayv2.GetConfPathDisplay(gatewayName)) + log.Info().Msg("Starting gateway...") + } + // --- Enrollment token path --- if enrollMethod == gatewayv2.EnrollMethodToken { enrollToken, err := cmd.Flags().GetString("token") @@ -289,7 +349,8 @@ var gatewayStartCmd = &cobra.Command{ // --domain flag takes priority; fall back to domain saved at enrollment time. // For enrollment flow with alreadyEnrolled, domain was set during original enrollment // and needs to be loaded from config. - if enrollMethod != gatewayv2.EnrollMethodToken || alreadyEnrolled { + isResourceAuth := enrollMethod == gatewayv2.EnrollMethodToken || enrollMethod == gatewayv2.EnrollMethodAws + if !isResourceAuth || alreadyEnrolled { if flagDomain, _ := cmd.Flags().GetString("domain"); flagDomain != "" { config.INFISICAL_URL = util.AppendAPIEndpoint(flagDomain) } else if storedDomain, _ := gatewayv2.LoadStoredDomain(gatewayName); storedDomain != "" { @@ -300,8 +361,8 @@ var gatewayStartCmd = &cobra.Command{ // Only use the stored token when no explicit identity credentials are provided. // If --token or --auth-method is set, the user wants the identity-based path. var runningWithStoredToken bool - if enrollMethod == gatewayv2.EnrollMethodToken { - // Just enrolled above; use the freshly saved token. + if isResourceAuth { + // Just enrolled / logged-in above; use the freshly saved token. runningWithStoredToken = true } else { hasExplicitCreds := cmd.Flags().Changed("token") || cmd.Flags().Changed("auth-method") @@ -584,6 +645,20 @@ var gatewaySystemdInstallCmd = &cobra.Command{ if installErr := gatewayv2.InstallEnrolledGatewaySystemdService(enrollResp.AccessToken, domain, gatewayName, relayName, serviceLogFile); installErr != nil { util.HandleError(installErr, "Unable to install systemd service") } + } else if enrollMethod == gatewayv2.EnrollMethodAws { + // --- AWS Auth path --- + // Don't perform the AWS login at install time — the gateway does it on each service + // start (so a fresh JWT is minted every restart, matching server-side tokenVersion). + gatewayID, _ := cmd.Flags().GetString("gateway-id") + if gatewayID == "" { + util.HandleError(errors.New("--gateway-id is required when --enroll-method=aws")) + } + + relayName, _ := util.GetRelayName(cmd, false, "") + + if installErr := gatewayv2.InstallAwsAuthGatewaySystemdService(gatewayID, domain, gatewayName, relayName, serviceLogFile); installErr != nil { + util.HandleError(installErr, "Unable to install systemd service") + } } else { // --- Machine identity token path --- token, tokenErr := util.GetInfisicalToken(cmd) @@ -681,8 +756,9 @@ func init() { gatewayStartCmd.Flags().String("name", "", "name of the gateway (deprecated, use positional argument instead)") _ = gatewayStartCmd.Flags().MarkDeprecated("name", "use positional argument instead: infisical gateway start ") gatewayStartCmd.Flags().String("token", "", "enrollment token or access token for authenticating with Infisical") - gatewayStartCmd.Flags().String("enroll-method", "", "enrollment method [token]. when set to 'token', uses --token as a one-time enrollment token") - gatewayStartCmd.Flags().String("domain", "", "domain of your self-hosted Infisical instance (used with --enroll-method=token)") + gatewayStartCmd.Flags().String("enroll-method", "", "gateway auth method [token, aws]. when set to 'token', uses --token as a one-time enrollment token. when set to 'aws', authenticates via signed STS GetCallerIdentity using --gateway-id") + gatewayStartCmd.Flags().String("gateway-id", "", "gateway id (required when --enroll-method=aws)") + gatewayStartCmd.Flags().String("domain", "", "domain of your self-hosted Infisical instance (used with --enroll-method=token or --enroll-method=aws)") gatewayStartCmd.Flags().String("auth-method", "", "login method [universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]. if not provided, you must set the token flag") gatewayStartCmd.Flags().String("organization-slug", "", "When set, this will scope the login session to the specified sub-organization the machine identity has access to. If left empty, the session defaults to the organization where the machine identity was created in.") gatewayStartCmd.Flags().String("client-id", "", "client id for universal auth") @@ -699,7 +775,8 @@ func init() { // Systemd install command flags (v2) gatewaySystemdInstallCmd.Flags().String("token", "", "enrollment token or access token for authenticating with Infisical") - gatewaySystemdInstallCmd.Flags().String("enroll-method", "", "enrollment method [token]. when set to 'token', uses --token as a one-time enrollment token") + gatewaySystemdInstallCmd.Flags().String("enroll-method", "", "gateway auth method [token, aws]. when set to 'token', uses --token as a one-time enrollment token. when set to 'aws', the gateway authenticates via AWS STS on each service start (requires --gateway-id)") + gatewaySystemdInstallCmd.Flags().String("gateway-id", "", "gateway id (required when --enroll-method=aws)") gatewaySystemdInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance") gatewaySystemdInstallCmd.Flags().String("name", "", "The name of the gateway (deprecated, use positional argument instead)") _ = gatewaySystemdInstallCmd.Flags().MarkDeprecated("name", "use positional argument instead: infisical gateway systemd install ") diff --git a/packages/gateway-v2/aws_auth.go b/packages/gateway-v2/aws_auth.go new file mode 100644 index 00000000..d5aa34d0 --- /dev/null +++ b/packages/gateway-v2/aws_auth.go @@ -0,0 +1,105 @@ +package gatewayv2 + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/go-resty/resty/v2" + infisicalSdkUtil "github.com/infisical/go-sdk/packages/util" +) + +const ( + INFISICAL_GATEWAY_ID_KEY = "INFISICAL_GATEWAY_ID" +) + +// LoginGatewayWithAws builds a SigV4-signed sts:GetCallerIdentity request using the local AWS +// credentials chain (instance metadata, env vars, profile, etc.) and exchanges it for a +// GATEWAY_ACCESS_TOKEN. The credentials themselves never leave the host — only the signature +// over a single STS API call. +// +// ctx is threaded through SigV4 signing so a shutdown signal during startup cancels the +// outbound STS verification cleanly instead of hanging the process. +func LoginGatewayWithAws(ctx context.Context, httpClient *resty.Client, gatewayID string) (string, error) { + if gatewayID == "" { + return "", errors.New("--gateway-id is required when --enroll-method=aws") + } + + awsCredentials, awsRegion, err := infisicalSdkUtil.RetrieveAwsCredentials() + if err != nil { + return "", fmt.Errorf("unable to retrieve AWS credentials (no instance role / no AWS env vars / no profile): %w", err) + } + + iamRequestURL := fmt.Sprintf("https://sts.%s.amazonaws.com/", awsRegion) + iamRequestBody := "Action=GetCallerIdentity&Version=2011-06-15" + + req, err := http.NewRequest(http.MethodPost, iamRequestURL, strings.NewReader(iamRequestBody)) + if err != nil { + return "", fmt.Errorf("error building STS request: %w", err) + } + + // Set every header that needs to be on the wire BEFORE signing — the SDK's signer + // reads req.Header to compute SignedHeaders, and any modification afterwards would + // invalidate the signature when the backend forwards the request to STS. + req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") + + hash := sha256.New() + hash.Write([]byte(iamRequestBody)) + payloadHash := fmt.Sprintf("%x", hash.Sum(nil)) + + signingTime := time.Now() + signer := v4.NewSigner() + if err := signer.SignHTTP(ctx, awsCredentials, req, payloadHash, "sts", awsRegion, signingTime); err != nil { + return "", fmt.Errorf("error signing STS request: %w", err) + } + + // Forward every signed header verbatim. Content-Length is computed by the receiving + // HTTP stack from the body, so we don't include it (and the signer doesn't sign it). + // Host isn't in req.Header in Go's http package — it's on req.URL.Host — so we add it + // explicitly with the same value the signer used internally. + headers := make(map[string]string) + for name, values := range req.Header { + if strings.ToLower(name) == "content-length" { + continue + } + headers[name] = values[0] + } + headers["Host"] = fmt.Sprintf("sts.%s.amazonaws.com", awsRegion) + + headersJSON, err := json.Marshal(headers) + if err != nil { + return "", fmt.Errorf("error marshalling headers: %w", err) + } + + resp, err := api.CallAwsAuthLoginGateway(httpClient, api.AwsAuthLoginGatewayRequest{ + Method: EnrollMethodAws, + GatewayID: gatewayID, + HTTPRequestMethod: req.Method, + IamRequestBody: base64.StdEncoding.EncodeToString([]byte(iamRequestBody)), + IamRequestHeaders: base64.StdEncoding.EncodeToString(headersJSON), + }) + if err != nil { + return "", err + } + + return resp.AccessToken, nil +} + +// LoadStoredGatewayID returns the persisted gateway id for a named gateway (set after first +// AWS-auth login so subsequent restarts don't need --gateway-id). +func LoadStoredGatewayID(name string) (string, error) { + return loadConfKey(name, INFISICAL_GATEWAY_ID_KEY) +} + +// SaveGatewayID persists the gateway id used during AWS-auth login. +func SaveGatewayID(name, gatewayID string) error { + return saveConfKey(name, INFISICAL_GATEWAY_ID_KEY, gatewayID) +} diff --git a/packages/gateway-v2/constants.go b/packages/gateway-v2/constants.go index 82e65f56..ce92df1b 100644 --- a/packages/gateway-v2/constants.go +++ b/packages/gateway-v2/constants.go @@ -16,6 +16,11 @@ const ( INFISICAL_TOKEN_ENV_NAME = "INFISICAL_TOKEN" INFISICAL_HTTP_PROXY_ACTION_HEADER = "x-infisical-action" + + // Gateway auth-method discriminators. Used both for matching the user's --enroll-method + // flag value and as the `method` field on the /v3/gateways/login request body. + EnrollMethodAws = "aws" + EnrollMethodToken = "token" ) type HttpProxyAction string diff --git a/packages/gateway-v2/enroll.go b/packages/gateway-v2/enroll.go index a79d589b..94957f06 100644 --- a/packages/gateway-v2/enroll.go +++ b/packages/gateway-v2/enroll.go @@ -12,7 +12,6 @@ const ( INFISICAL_GATEWAY_ACCESS_TOKEN_KEY = "INFISICAL_GATEWAY_ACCESS_TOKEN" INFISICAL_GATEWAY_DOMAIN_KEY = "INFISICAL_GATEWAY_DOMAIN" INFISICAL_GATEWAY_ENROLLMENT_TOKEN_KEY = "INFISICAL_GATEWAY_ENROLLMENT_TOKEN" - EnrollMethodToken = "token" ) // gatewayConfPath returns the path to the gateway config file scoped by name. diff --git a/packages/gateway-v2/systemd.go b/packages/gateway-v2/systemd.go index c967d7f4..622c6b9e 100644 --- a/packages/gateway-v2/systemd.go +++ b/packages/gateway-v2/systemd.go @@ -119,6 +119,64 @@ func InstallEnrolledGatewaySystemdService(accessToken string, domain string, nam return nil } +// InstallAwsAuthGatewaySystemdService installs the systemd service for a gateway using AWS Auth. +// Unlike the token-auth flow, no JWT is written into the env file — the gateway performs a +// fresh STS-signed login on each service start using whatever AWS credentials it can resolve +// (instance role, env vars, shared profile). We just persist the gateway id, domain, and name +// so `gateway start` can re-authenticate. +func InstallAwsAuthGatewaySystemdService(gatewayID string, domain string, name string, relayName string, serviceLogFile string) error { + if runtime.GOOS != "linux" { + log.Info().Msg("Skipping systemd service installation - not on Linux") + return nil + } + + if os.Geteuid() != 0 { + log.Info().Msg("Skipping systemd service installation - not running as root/sudo") + return nil + } + + configDir := "/etc/infisical" + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + + configContent := fmt.Sprintf("%s=%s\n", INFISICAL_GATEWAY_ID_KEY, gatewayID) + configContent += "INFISICAL_GATEWAY_ENROLL_METHOD=aws\n" + if domain != "" { + configContent += fmt.Sprintf("INFISICAL_API_URL=%s\n", domain) + } + if name != "" { + configContent += fmt.Sprintf("%s=%s\n", GATEWAY_NAME_ENV_NAME, name) + } + if relayName != "" { + configContent += fmt.Sprintf("%s=%s\n", RELAY_NAME_ENV_NAME, relayName) + } + + environmentFilePath := filepath.Join(configDir, "gateway.conf") + if err := os.WriteFile(environmentFilePath, []byte(configContent), 0600); err != nil { + return fmt.Errorf("failed to write environment file: %v", err) + } + + if err := util.WriteSystemdServiceFile(serviceLogFile, environmentFilePath, "infisical-gateway", "gateway", "Infisical Gateway Service"); err != nil { + return fmt.Errorf("failed to write systemd service file: %v", err) + } + + if err := util.WriteLogrotateFile(serviceLogFile, "infisical-gateway"); err != nil { + return fmt.Errorf("failed to write logrotate file: %v", err) + } + + reloadCmd := exec.Command("systemctl", "daemon-reload") + if err := reloadCmd.Run(); err != nil { + return fmt.Errorf("failed to reload systemd: %v", err) + } + + log.Info().Msg("Successfully installed systemd service") + log.Info().Msg("To start the service, run: sudo systemctl start infisical-gateway") + log.Info().Msg("To enable the service on boot, run: sudo systemctl enable infisical-gateway") + + return nil +} + func UninstallGatewaySystemdService() error { if runtime.GOOS != "linux" { log.Info().Msg("Skipping systemd service uninstallation - not on Linux")